@softize/opus 25.2.0 → 25.3.1

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.
@@ -38,6 +38,7 @@ import {
38
38
  Calendar as CalendarIcon,
39
39
  Check,
40
40
  ChevronDown,
41
+ ChevronRight,
41
42
  Eraser,
42
43
  Filter,
43
44
  RotateCw,
@@ -65,6 +66,7 @@ import {
65
66
  useLookupAction,
66
67
  type DictLike,
67
68
  } from "../../drivers/react.tsx";
69
+ import { useGroupedListAction } from "./grouped-list.ts";
68
70
  import { cn } from "../../lib/cn.ts";
69
71
  import { FillBoundary, useFillLeaf } from "./fill-region.ts";
70
72
  import { objectSchemaShape } from "../../lib/object-schema.ts";
@@ -82,6 +84,7 @@ import {
82
84
  PaginationPrevious,
83
85
  } from "../primitives/pagination.tsx";
84
86
  import { Button } from "../primitives/button.tsx";
87
+ import { focusRing } from "../primitives/control.ts";
85
88
  import { ButtonGroup } from "../primitives/button-group.tsx";
86
89
  import { Calendar } from "../primitives/calendar.tsx";
87
90
  import { Checkbox } from "../primitives/checkbox.tsx";
@@ -205,6 +208,8 @@ export interface ActionListState {
205
208
  limit: number | null;
206
209
  /** Página atual (1-based). Mudar filtro/busca/sort/período volta pra 1. */
207
210
  page: number;
211
+ /** Dimensão ativa de agrupamento. null mantém a listagem plana. */
212
+ groupBy?: string | null;
208
213
  }
209
214
 
210
215
  /** Um estado vazio/default — útil pra montar o inicial em quem controla. */
@@ -220,6 +225,7 @@ export function emptyListState(): ActionListState {
220
225
  columns: null,
221
226
  limit: null,
222
227
  page: 1,
228
+ groupBy: null,
223
229
  };
224
230
  }
225
231
 
@@ -270,6 +276,7 @@ export interface ListActionLike {
270
276
  sort?: { fields: string[]; default?: SortSpec[] } | undefined;
271
277
  text?: { fields: string[]; placeholder?: unknown } | undefined;
272
278
  periods?: PeriodSpec[] | undefined;
279
+ grouping?: { fields: string[] } | undefined;
273
280
  }
274
281
 
275
282
  /** Estado compartilhado pelos filtros declarativos, independente da listagem. */
@@ -1750,6 +1757,7 @@ export function ActionList<TInput, TItem>({
1750
1757
  onStateChange,
1751
1758
  }: ActionListProps<TInput, TItem>) {
1752
1759
  const confirmBodyId = useId();
1760
+ const groupRegionId = useId();
1753
1761
  const { layout, ref: rootRef } = useFillLeaf<HTMLDivElement>(layoutProp);
1754
1762
  // Query-backed (useListAction): busca declarativa por [name, input] E refetch
1755
1763
  // automático quando um form/trigger invalida a action (`invalidates: ['x.list']`).
@@ -1778,6 +1786,7 @@ export function ActionList<TInput, TItem>({
1778
1786
  columns: raw.columns ?? null,
1779
1787
  limit: raw.limit ?? null,
1780
1788
  page: raw.page ?? 1,
1789
+ groupBy: raw.groupBy ?? null,
1781
1790
  };
1782
1791
  const setState = (next: ActionListState): void => {
1783
1792
  if (onStateChange !== undefined) onStateChange(next);
@@ -1804,6 +1813,34 @@ export function ActionList<TInput, TItem>({
1804
1813
  ),
1805
1814
  ];
1806
1815
  const activeView = state.view ?? viewEntries[0]?.[0] ?? "table";
1816
+ const groupingFields = action.grouping?.fields ?? [];
1817
+ const dicts = useDicts();
1818
+ const availableGroupingFields = groupingFields.filter((field) => {
1819
+ if (action.filters?.group !== undefined) return false;
1820
+ const spec = action.filters?.[field];
1821
+ if (
1822
+ spec?.type !== "select" ||
1823
+ (spec.options?.kind !== "static" && spec.options?.kind !== "dictionary")
1824
+ )
1825
+ return false;
1826
+ const options = [
1827
+ ...optionsFor(field, spec, filterOptions),
1828
+ ...dictOptionsFor(spec, dicts),
1829
+ ].filter(
1830
+ (option, index, all) =>
1831
+ all.findIndex((candidate) => candidate.value === option.value) ===
1832
+ index,
1833
+ );
1834
+ return options.length > 0 && options.length <= 12;
1835
+ });
1836
+ const requestedGroupBy = state.groupBy ?? null;
1837
+ const activeGroupBy =
1838
+ children === undefined &&
1839
+ activeView === "table" &&
1840
+ requestedGroupBy !== null &&
1841
+ availableGroupingFields.includes(requestedGroupBy)
1842
+ ? requestedGroupBy
1843
+ : null;
1807
1844
  const hasSegment = children === undefined && viewEntries.length > 1;
1808
1845
  const periods = action.periods ?? [];
1809
1846
  const hasToolbar =
@@ -1860,16 +1897,109 @@ export function ActionList<TInput, TItem>({
1860
1897
  } as TInput;
1861
1898
  }, [input, state, pageSize, filterDefaults]);
1862
1899
 
1863
- const inputKey = JSON.stringify(effectiveInput);
1864
- const { items, total, isLoading, isFetching, isError, error, refetch } =
1865
- useListAction<TItem, Record<string, unknown>>(
1866
- action as unknown as { name: string; kind: "list" },
1867
- effectiveInput as Record<string, unknown>,
1900
+ const inputKey = JSON.stringify([effectiveInput, activeGroupBy]);
1901
+ const groupSpec =
1902
+ activeGroupBy === null ? undefined : action.filters?.[activeGroupBy];
1903
+ const declaredGroupOptions =
1904
+ groupSpec === undefined
1905
+ ? []
1906
+ : [
1907
+ ...optionsFor(activeGroupBy ?? "", groupSpec, filterOptions),
1908
+ ...dictOptionsFor(groupSpec, dicts),
1909
+ ].filter(
1910
+ (option, index, all) =>
1911
+ all.findIndex((candidate) => candidate.value === option.value) ===
1912
+ index,
1913
+ );
1914
+ const selectedGroupFilter =
1915
+ activeGroupBy === null
1916
+ ? undefined
1917
+ : (state.filters[activeGroupBy] ?? filterDefaults?.[activeGroupBy]);
1918
+ const groupOptions = declaredGroupOptions.filter((option) =>
1919
+ Array.isArray(selectedGroupFilter) && selectedGroupFilter.length > 0
1920
+ ? selectedGroupFilter.includes(option.value)
1921
+ : typeof selectedGroupFilter === "string" && selectedGroupFilter !== ""
1922
+ ? selectedGroupFilter === option.value
1923
+ : true,
1924
+ );
1925
+ const [groupPaging, setGroupPaging] = useState<{
1926
+ key: string;
1927
+ pages: Record<string, number>;
1928
+ }>({ key: "", pages: {} });
1929
+ const groupPages = groupPaging.key === inputKey ? groupPaging.pages : {};
1930
+ const [groupCollapse, setGroupCollapse] = useState<{
1931
+ key: string;
1932
+ values: Set<string>;
1933
+ }>({ key: "", values: new Set() });
1934
+ const collapsedGroups =
1935
+ groupCollapse.key === inputKey ? groupCollapse.values : new Set<string>();
1936
+ const flat = useListAction<TItem, Record<string, unknown>>(
1937
+ action as unknown as { name: string; kind: "list" },
1938
+ effectiveInput as Record<string, unknown>,
1939
+ { enabled: activeGroupBy === null },
1940
+ );
1941
+ const grouped = useGroupedListAction<TItem, Record<string, unknown>>(
1942
+ action as unknown as { name: string; kind: "list" },
1943
+ effectiveInput as Record<string, unknown>,
1944
+ activeGroupBy,
1945
+ groupOptions.map((option) => option.value),
1946
+ groupSpec?.multiple === true,
1947
+ groupPages,
1948
+ state.limit ?? pageSize,
1949
+ );
1950
+ const items =
1951
+ activeGroupBy === null
1952
+ ? flat.items
1953
+ : grouped.flatMap((group) => group.items);
1954
+ const visibleItems =
1955
+ activeGroupBy === null
1956
+ ? items
1957
+ : grouped
1958
+ .filter((group) => !collapsedGroups.has(group.value))
1959
+ .flatMap((group) => group.items);
1960
+ const total =
1961
+ activeGroupBy === null
1962
+ ? flat.total
1963
+ : grouped.every((group) => group.total !== undefined)
1964
+ ? grouped.reduce((sum, group) => sum + (group.total ?? 0), 0)
1965
+ : undefined;
1966
+ const isLoading =
1967
+ activeGroupBy === null
1968
+ ? flat.isLoading
1969
+ : grouped.length > 0 && grouped.some((group) => group.isLoading);
1970
+ const isFetching =
1971
+ activeGroupBy === null
1972
+ ? flat.isFetching
1973
+ : grouped.some((group) => group.isFetching);
1974
+ const missingGroupTotal =
1975
+ activeGroupBy !== null &&
1976
+ grouped.some(
1977
+ (group) =>
1978
+ !group.isLoading && !group.isError && group.total === undefined,
1868
1979
  );
1980
+ const isError =
1981
+ activeGroupBy === null
1982
+ ? flat.isError
1983
+ : missingGroupTotal || grouped.some((group) => group.isError);
1984
+ const error =
1985
+ activeGroupBy === null
1986
+ ? flat.error
1987
+ : missingGroupTotal
1988
+ ? {
1989
+ code: "action_list.grouping_total_missing",
1990
+ category: "internal" as const,
1991
+ message: "Não foi possível carregar os grupos desta lista.",
1992
+ severity: "error" as const,
1993
+ retriable: false,
1994
+ }
1995
+ : grouped.find((group) => group.error !== undefined)?.error;
1996
+ const refetch = async (): Promise<void> => {
1997
+ if (activeGroupBy === null) return flat.refetch();
1998
+ await Promise.all(grouped.map((group) => group.refetch()));
1999
+ };
1869
2000
  // — Colunas: prop explícita > derivadas do contrato (+ células custom). O column
1870
2001
  // picker (state.columns) escolhe o subconjunto visível; null = o default (!hidden).
1871
2002
  const visibleKeys = state.columns;
1872
- const dicts = useDicts();
1873
2003
  const derived: Array<ActionListColumn<TItem> & { spec?: ListColumnSpec }> =
1874
2004
  useMemo(() => {
1875
2005
  if (columns !== undefined) return columns;
@@ -1972,7 +2102,7 @@ export function ActionList<TInput, TItem>({
1972
2102
  const selectable = (item: TItem): boolean =>
1973
2103
  batchActions.some((a) => a.can?.(item) ?? true);
1974
2104
  const selectableItems =
1975
- batchActions.length > 0 ? items.filter(selectable) : [];
2105
+ batchActions.length > 0 ? visibleItems.filter(selectable) : [];
1976
2106
  const hasSelection =
1977
2107
  batchActions.length > 0 &&
1978
2108
  selectableItems.length > 0 &&
@@ -1981,7 +2111,7 @@ export function ActionList<TInput, TItem>({
1981
2111
  // Página/recorte novo → seleção limpa (os ids podem nem estar mais na tela).
1982
2112
  setSelected(new Set());
1983
2113
  }, [inputKey]);
1984
- const selectedItems = items.filter((it) => selected.has(rowId(it)));
2114
+ const selectedItems = visibleItems.filter((it) => selected.has(rowId(it)));
1985
2115
  const eligibleFor = (a: ActionListBatchAction<TItem>): TItem[] =>
1986
2116
  selectedItems.filter((it) => a.can?.(it) ?? true);
1987
2117
  const runBatch = async (a: ActionListBatchAction<TItem>): Promise<void> => {
@@ -2065,6 +2195,41 @@ export function ActionList<TInput, TItem>({
2065
2195
  data-slot="action-list-display"
2066
2196
  >
2067
2197
  <div className="space-y-3">
2198
+ {children === undefined &&
2199
+ activeView === "table" &&
2200
+ availableGroupingFields.length > 0 && (
2201
+ <>
2202
+ <div className="space-y-2">
2203
+ <span className="text-xs font-medium text-muted-foreground">
2204
+ Agrupar por
2205
+ </span>
2206
+ <Select
2207
+ aria-label="Agrupar por"
2208
+ size="sm"
2209
+ className="w-full"
2210
+ value={activeGroupBy ?? "__none__"}
2211
+ onChange={(value) =>
2212
+ setState({
2213
+ ...state,
2214
+ groupBy: value === "__none__" ? null : value,
2215
+ page: 1,
2216
+ })
2217
+ }
2218
+ options={[
2219
+ { value: "__none__", label: "Sem agrupamento" },
2220
+ ...availableGroupingFields.map((field) => ({
2221
+ value: field,
2222
+ label: text(
2223
+ action.filters?.[field]?.label,
2224
+ field,
2225
+ ),
2226
+ })),
2227
+ ]}
2228
+ />
2229
+ </div>
2230
+ <Separator />
2231
+ </>
2232
+ )}
2068
2233
  {activeView === "table" && pickerColumns.length > 0 && (
2069
2234
  <>
2070
2235
  <div className="space-y-2">
@@ -2107,7 +2272,9 @@ export function ActionList<TInput, TItem>({
2107
2272
  )}
2108
2273
  <div className="space-y-2">
2109
2274
  <span className="text-xs font-medium text-muted-foreground">
2110
- Itens por página
2275
+ {activeGroupBy === null
2276
+ ? "Itens por página"
2277
+ : "Itens por grupo"}
2111
2278
  </span>
2112
2279
  <Select
2113
2280
  size="sm"
@@ -2152,43 +2319,54 @@ export function ActionList<TInput, TItem>({
2152
2319
  (hasSelection ? 1 : 0) +
2153
2320
  (rowActions !== undefined ? 1 : 0),
2154
2321
  );
2155
- const tableRows = items.map((item, idx) => (
2156
- <TableRow
2157
- // eslint-disable-next-line react/no-array-index-key
2158
- key={idx}
2159
- className={onRowClick !== undefined ? "cursor-pointer" : undefined}
2160
- onClick={onRowClick !== undefined ? () => onRowClick(item) : undefined}
2161
- >
2162
- {hasSelection && (
2163
- <TableCell className="w-px" onClick={(e) => e.stopPropagation()}>
2164
- <Checkbox
2165
- aria-label="Selecionar linha"
2166
- disabled={!selectable(item)}
2167
- checked={selected.has(rowId(item))}
2168
- onCheckedChange={(v) => {
2169
- const next = new Set(selected);
2170
- if (v === true) next.add(rowId(item));
2171
- else next.delete(rowId(item));
2172
- setSelected(next);
2173
- }}
2174
- />
2175
- </TableCell>
2176
- )}
2177
- {derived.map((col) => (
2178
- <TableCell key={col.key} className={col.className}>
2179
- {col.cell(item)}
2180
- </TableCell>
2181
- ))}
2182
- {rowActions !== undefined && (
2183
- <TableCell
2184
- className="w-px whitespace-nowrap text-right"
2185
- onClick={(e) => e.stopPropagation()}
2186
- >
2187
- <RowActions>{rowActions(item)}</RowActions>
2188
- </TableCell>
2189
- )}
2190
- </TableRow>
2191
- ));
2322
+ const renderTableRows = (
2323
+ rows: TItem[],
2324
+ options?: { hidden?: boolean; idPrefix?: string },
2325
+ ) =>
2326
+ rows.map((item, idx) => (
2327
+ <TableRow
2328
+ // eslint-disable-next-line react/no-array-index-key
2329
+ key={idx}
2330
+ id={
2331
+ options?.idPrefix === undefined
2332
+ ? undefined
2333
+ : `${options.idPrefix}-${idx}`
2334
+ }
2335
+ hidden={options?.hidden}
2336
+ className={onRowClick !== undefined ? "cursor-pointer" : undefined}
2337
+ onClick={onRowClick !== undefined ? () => onRowClick(item) : undefined}
2338
+ >
2339
+ {hasSelection && (
2340
+ <TableCell className="w-px" onClick={(e) => e.stopPropagation()}>
2341
+ <Checkbox
2342
+ aria-label="Selecionar linha"
2343
+ disabled={!selectable(item)}
2344
+ checked={selected.has(rowId(item))}
2345
+ onCheckedChange={(v) => {
2346
+ const next = new Set(selected);
2347
+ if (v === true) next.add(rowId(item));
2348
+ else next.delete(rowId(item));
2349
+ setSelected(next);
2350
+ }}
2351
+ />
2352
+ </TableCell>
2353
+ )}
2354
+ {derived.map((col) => (
2355
+ <TableCell key={col.key} className={col.className}>
2356
+ {col.cell(item)}
2357
+ </TableCell>
2358
+ ))}
2359
+ {rowActions !== undefined && (
2360
+ <TableCell
2361
+ className="w-px whitespace-nowrap text-right"
2362
+ onClick={(e) => e.stopPropagation()}
2363
+ >
2364
+ <RowActions>{rowActions(item)}</RowActions>
2365
+ </TableCell>
2366
+ )}
2367
+ </TableRow>
2368
+ ));
2369
+ const tableRows = renderTableRows(items);
2192
2370
  let content: ReactNode;
2193
2371
  if (children !== undefined) {
2194
2372
  // Modo COMPOSIÇÃO: o layout é de quem compõe; a toolbar e os estados seguem daqui.
@@ -2263,14 +2441,33 @@ export function ActionList<TInput, TItem>({
2263
2441
  {rowActions !== undefined && <TableHead className="w-px" />}
2264
2442
  </TableRow>
2265
2443
  </TableHeader>
2266
- <TableBody
2267
- className={
2268
- tableStateActive
2269
- ? "h-full [&_[data-slot=data-state]]:h-full"
2270
- : undefined
2271
- }
2272
- >
2273
- {tableFillsAvailableHeight ? (
2444
+ {activeGroupBy === null ? (
2445
+ <TableBody
2446
+ className={
2447
+ tableStateActive
2448
+ ? "h-full [&_[data-slot=data-state]]:h-full"
2449
+ : undefined
2450
+ }
2451
+ >
2452
+ {tableFillsAvailableHeight ? (
2453
+ <DataState
2454
+ loading={initialLoading}
2455
+ error={isError ? error : null}
2456
+ empty={isEmpty}
2457
+ emptyMessage={emptyMessage}
2458
+ {...(errorMessage !== undefined ? { errorMessage } : {})}
2459
+ {...(retryLabel !== undefined ? { retryLabel } : {})}
2460
+ onRetry={() => refetch()}
2461
+ colSpan={tableColumnCount}
2462
+ >
2463
+ {tableRows}
2464
+ </DataState>
2465
+ ) : (
2466
+ tableRows
2467
+ )}
2468
+ </TableBody>
2469
+ ) : tableFillsAvailableHeight && tableStateActive ? (
2470
+ <TableBody className="h-full [&_[data-slot=data-state]]:h-full">
2274
2471
  <DataState
2275
2472
  loading={initialLoading}
2276
2473
  error={isError ? error : null}
@@ -2281,12 +2478,127 @@ export function ActionList<TInput, TItem>({
2281
2478
  onRetry={() => refetch()}
2282
2479
  colSpan={tableColumnCount}
2283
2480
  >
2284
- {tableRows}
2481
+ {null}
2285
2482
  </DataState>
2286
- ) : (
2287
- tableRows
2288
- )}
2289
- </TableBody>
2483
+ </TableBody>
2484
+ ) : (
2485
+ grouped.map((group, groupIndex) => {
2486
+ const option = groupOptions.find(
2487
+ (candidate) => candidate.value === group.value,
2488
+ );
2489
+ const collapsed = collapsedGroups.has(group.value);
2490
+ const loadedPages = groupPages[group.value] ?? 1;
2491
+ const canLoadMore =
2492
+ group.total !== undefined && group.items.length < group.total;
2493
+ const groupLabel = option?.label ?? group.value;
2494
+ const groupId = `${groupRegionId}-${groupIndex}-${group.value.replace(
2495
+ /[^a-zA-Z0-9_-]/g,
2496
+ "-",
2497
+ )}`;
2498
+ const bodyId = `${groupId}-body`;
2499
+ const controlledIds = [
2500
+ ...group.items.map((_, index) => `${bodyId}-${index}`),
2501
+ ...(canLoadMore ? [`${bodyId}-load-more`] : []),
2502
+ ];
2503
+ return (
2504
+ <TableBody
2505
+ key={group.value}
2506
+ data-group={group.value}
2507
+ aria-labelledby={`${groupId}-label`}
2508
+ >
2509
+ <TableRow className="!bg-muted/40 hover:!bg-muted/60">
2510
+ <TableHead
2511
+ id={`${groupId}-label`}
2512
+ scope="rowgroup"
2513
+ colSpan={tableColumnCount}
2514
+ className="h-auto py-1.5 text-foreground"
2515
+ >
2516
+ <button
2517
+ type="button"
2518
+ aria-expanded={!collapsed}
2519
+ aria-controls={
2520
+ controlledIds.length > 0
2521
+ ? controlledIds.join(" ")
2522
+ : undefined
2523
+ }
2524
+ aria-label={`${groupLabel}, ${
2525
+ group.total === undefined
2526
+ ? "carregando"
2527
+ : `${group.total} ${group.total === 1 ? "item" : "itens"}`
2528
+ }`}
2529
+ className={cn(
2530
+ "flex w-full items-center gap-2 rounded-sm text-left font-medium outline-none",
2531
+ focusRing,
2532
+ )}
2533
+ onClick={() => {
2534
+ const next = new Set(collapsedGroups);
2535
+ if (collapsed) next.delete(group.value);
2536
+ else {
2537
+ next.add(group.value);
2538
+ const hidden = new Set(group.items.map(rowId));
2539
+ setSelected(
2540
+ new Set(
2541
+ [...selected].filter((id) => !hidden.has(id)),
2542
+ ),
2543
+ );
2544
+ }
2545
+ setGroupCollapse({ key: inputKey, values: next });
2546
+ }}
2547
+ >
2548
+ {collapsed ? (
2549
+ <ChevronRight className="size-4" />
2550
+ ) : (
2551
+ <ChevronDown className="size-4" />
2552
+ )}
2553
+ <span className="flex items-center gap-1.5">
2554
+ {option?.content ?? option?.label ?? group.value}
2555
+ </span>
2556
+ <span className="text-muted-foreground">
2557
+ {group.total ?? "…"}
2558
+ </span>
2559
+ </button>
2560
+ </TableHead>
2561
+ </TableRow>
2562
+ {renderTableRows(group.items, {
2563
+ hidden: collapsed,
2564
+ idPrefix: bodyId,
2565
+ })}
2566
+ {canLoadMore && (
2567
+ <TableRow
2568
+ id={`${bodyId}-load-more`}
2569
+ hidden={collapsed}
2570
+ className="hover:bg-transparent"
2571
+ >
2572
+ <TableCell
2573
+ colSpan={tableColumnCount}
2574
+ className="text-center"
2575
+ >
2576
+ <Button
2577
+ size="sm"
2578
+ variant="ghost"
2579
+ aria-label={`Carregar mais de ${groupLabel}`}
2580
+ busy={group.isFetching}
2581
+ onClick={() =>
2582
+ setGroupPaging((current) => ({
2583
+ key: inputKey,
2584
+ pages: {
2585
+ ...(current.key === inputKey
2586
+ ? current.pages
2587
+ : {}),
2588
+ [group.value]: loadedPages + 1,
2589
+ },
2590
+ }))
2591
+ }
2592
+ >
2593
+ Carregar mais
2594
+ </Button>
2595
+ </TableCell>
2596
+ </TableRow>
2597
+ )}
2598
+ </TableBody>
2599
+ );
2600
+ })
2601
+ )}
2290
2602
  </Table>
2291
2603
  );
2292
2604
  }
@@ -2365,7 +2677,14 @@ export function ActionList<TInput, TItem>({
2365
2677
  : 1;
2366
2678
  const goTo = (page: number): void => setState({ ...state, page });
2367
2679
  const footer =
2368
- total !== undefined && !isError ? (
2680
+ total !== undefined && !isError && activeGroupBy !== null ? (
2681
+ <div
2682
+ 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"
2684
+ >
2685
+ {total} {total === 1 ? "item" : "itens"} no total
2686
+ </div>
2687
+ ) : total !== undefined && !isError ? (
2369
2688
  <div
2370
2689
  data-slot="action-list-footer"
2371
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"
@@ -2459,7 +2778,9 @@ export function ActionList<TInput, TItem>({
2459
2778
  className={cn(
2460
2779
  "min-h-0",
2461
2780
  layout === "fill" && "overflow-auto",
2462
- busy && items.length > 0 && "pointer-events-none opacity-60",
2781
+ busy &&
2782
+ items.length > 0 &&
2783
+ "pointer-events-none opacity-60",
2463
2784
  )}
2464
2785
  >
2465
2786
  {body}
@@ -2568,6 +2889,11 @@ export function listParamsToState(
2568
2889
  const periodParam = params.get("period");
2569
2890
  const range =
2570
2891
  periodParam?.match(/^(\d{4}-\d{2}-\d{2})\.\.(\d{4}-\d{2}-\d{2})$/) ?? null;
2892
+ const groupParam = params.get("group");
2893
+ const groupBy =
2894
+ groupParam !== null && (action.grouping?.fields ?? []).includes(groupParam)
2895
+ ? groupParam
2896
+ : null;
2571
2897
  return {
2572
2898
  q: params.get("q") ?? "",
2573
2899
  sort,
@@ -2579,6 +2905,7 @@ export function listParamsToState(
2579
2905
  columns: cols !== null && cols !== "" ? cols.split(",") : null,
2580
2906
  limit: Number.isInteger(limitParam) && limitParam > 0 ? limitParam : null,
2581
2907
  page: Number.isInteger(page) && page > 1 ? page : 1,
2908
+ groupBy,
2582
2909
  };
2583
2910
  }
2584
2911
 
@@ -2622,6 +2949,13 @@ export function listStateToParams(
2622
2949
  if (state.columns !== null) qs.set("cols", state.columns.join(","));
2623
2950
  if (state.limit !== null) qs.set("limit", String(state.limit));
2624
2951
  if (state.page > 1) qs.set("page", String(state.page));
2952
+ if (
2953
+ state.groupBy !== undefined &&
2954
+ state.groupBy !== null &&
2955
+ (action.grouping?.fields ?? []).includes(state.groupBy)
2956
+ ) {
2957
+ qs.set("group", state.groupBy);
2958
+ }
2625
2959
  const s = qs.toString();
2626
2960
  return s === "" ? "" : `?${s}`;
2627
2961
  }
@@ -14,7 +14,9 @@ export interface FloatingLayerProps {
14
14
  /**
15
15
  * Esc enquanto a pessoa está na camada, com ou sem modal aberto. É chamado uma vez por tecla.
16
16
  * Nenhum modal fecha nesse caso, e o Opus não fecha a camada sozinho, porque o estado de aberto
17
- * pertence ao consumidor.
17
+ * pertence ao consumidor. Sem o handler, o Esc com a pessoa na camada fecha o modal de trabalho
18
+ * aberto: passe `undefined` enquanto a camada não tiver o que fechar, como com o painel fechado e
19
+ * só o botão que o abre.
18
20
  */
19
21
  onEscapeKeyDown?: (event: KeyboardEvent) => void;
20
22
  }
@@ -49,9 +51,7 @@ export function FloatingLayer({
49
51
  const element = document.createElement("div");
50
52
  element.setAttribute("data-slot", "floating-layer");
51
53
  document.body.appendChild(element);
52
- const unregister = registerFloatingLayer(element, (event) =>
53
- escapeRef.current?.(event),
54
- );
54
+ const unregister = registerFloatingLayer(element, () => escapeRef.current);
55
55
  setHost(element);
56
56
  // Desfaz o registro, retira o hospedeiro e, se o foco estava nele com um modal aberto,
57
57
  // devolve o foco ao modal.