@softize/opus 29.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.
- package/CHANGELOG.md +137 -0
- package/bin/lib/check.mjs +178 -94
- package/bin/lib/copy.mjs +2970 -1801
- package/docs/adr/0043-neutral-surfaces-and-interactions-use-opaque-role-tokens.md +52 -0
- package/docs/adr/0044-neutral-appearance-derives-from-bounded-axes-per-mode.md +27 -2
- package/docs/adr/0045-surface-replaces-content-and-card.md +3 -0
- package/docs/adr/0060-surface-composition-separates-chrome-and-intro.md +142 -0
- package/docs/adr/0062-listas-contidas-e-texto-flexivel.md +68 -0
- package/docs/adr/0063-metadados-das-superficies.md +44 -0
- package/docs/adr/0064-action-list-com-itens-e-fontes-externas.md +47 -0
- package/docs/adr/0065-presentation-composes-settings-sections.md +295 -0
- package/docs/adr/README.md +8 -0
- package/package.json +1 -1
- package/registry/skills/build-opus-ui/SKILL.md +21 -19
- package/registry/skills/build-opus-ui/references/evaluations.md +21 -10
- package/registry/skills/build-opus-ui/references/ui-patterns.md +33 -28
- package/src/core/presentation.ts +938 -32
- package/src/core/types.ts +2 -1
- package/src/ui/components/patterns/form.tsx +2 -0
- package/src/ui/components/patterns/grouped-list.ts +2 -2
- package/src/ui/components/patterns/list.tsx +588 -365
- package/src/ui/components/patterns/page-heading-context.tsx +12 -5
- package/src/ui/components/patterns/page.tsx +238 -181
- package/src/ui/components/patterns/presentation-fields.tsx +246 -0
- package/src/ui/components/patterns/presentation-sections.tsx +494 -0
- package/src/ui/components/patterns/presentation.tsx +1142 -229
- package/src/ui/components/patterns/structural-header.tsx +47 -34
- package/src/ui/components/patterns/surface-anatomy.ts +27 -217
- package/src/ui/components/patterns/surface.tsx +55 -37
- package/src/ui/components/patterns/trigger.tsx +10 -2
- package/src/ui/components/primitives/close-button.tsx +11 -3
- package/src/ui/components/primitives/command.tsx +2 -8
- package/src/ui/components/primitives/detail.tsx +1 -1
- package/src/ui/components/primitives/dialog.tsx +59 -89
- package/src/ui/components/primitives/drawer.tsx +52 -87
- package/src/ui/components/primitives/field.tsx +1 -1
- package/src/ui/components/primitives/pagination.tsx +3 -2
- package/src/ui/components/primitives/table.tsx +3 -3
- package/src/ui/components/primitives/tabs.tsx +13 -2
- package/src/ui/docs/content/action-form.md +2 -1
- package/src/ui/docs/content/action-list.md +146 -73
- package/src/ui/docs/content/action-trigger.md +5 -0
- package/src/ui/docs/content/button.md +5 -6
- package/src/ui/docs/content/command.md +62 -50
- package/src/ui/docs/content/compositions.md +129 -459
- package/src/ui/docs/content/dialog.md +72 -101
- package/src/ui/docs/content/drawer.md +69 -92
- package/src/ui/docs/content/field.md +2 -1
- package/src/ui/docs/content/page.md +207 -386
- package/src/ui/docs/content/presentation.md +371 -23
- package/src/ui/docs/content/separator.md +1 -1
- package/src/ui/docs/content/surface.md +94 -116
- package/src/ui/docs/content/table.md +7 -0
- package/src/ui/docs/content/tokens.md +18 -4
- package/src/ui/drivers/context.tsx +11 -4
- package/src/ui/drivers/react.tsx +27 -7
- package/src/ui/meta.ts +5 -5
- package/src/ui/react.tsx +38 -8
- 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
|
-
|
|
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
|
-
|
|
353
|
-
|
|
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
|
|
364
|
-
toolbarActions?:
|
|
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.
|
|
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. */
|
|
@@ -1524,7 +1562,7 @@ export function ActionFilterBar({
|
|
|
1524
1562
|
return (
|
|
1525
1563
|
<TooltipProvider>
|
|
1526
1564
|
<div data-slot="action-filter-bar" className="space-y-2">
|
|
1527
|
-
<div className="flex min-w-0 items-end gap-3">
|
|
1565
|
+
<div className="flex min-w-0 flex-wrap items-end gap-3">
|
|
1528
1566
|
{periodPosition === "before" && periodControl}
|
|
1529
1567
|
{inlineSpecs.map(([name, spec]) => {
|
|
1530
1568
|
if (name === periodBasisEntry?.[0]) {
|
|
@@ -1636,7 +1674,7 @@ export function ActionFilterBar({
|
|
|
1636
1674
|
{!searchAtActionsEnd && searchBox}
|
|
1637
1675
|
<div
|
|
1638
1676
|
data-slot="action-filter-actions"
|
|
1639
|
-
className="ml-auto flex min-w-0 items-end gap-2"
|
|
1677
|
+
className="ml-auto flex min-w-0 max-w-full flex-wrap items-end gap-2"
|
|
1640
1678
|
>
|
|
1641
1679
|
{searchAtActionsEnd && searchBox}
|
|
1642
1680
|
{(modalSpecs.length > 0 ||
|
|
@@ -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 =
|
|
1817
|
-
|
|
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 =
|
|
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
|
-
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
|
|
1913
|
-
|
|
1914
|
-
|
|
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 =
|
|
1990
|
-
|
|
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;
|
|
@@ -2074,7 +2116,7 @@ export function ActionList<TInput, TItem>({
|
|
|
2074
2116
|
header: text(c.label, c.key),
|
|
2075
2117
|
sortable: c.sortable === true,
|
|
2076
2118
|
spec: c,
|
|
2077
|
-
className: cn(c.fit === true && "
|
|
2119
|
+
className: cn(c.fit === true && "whitespace-nowrap wrap-normal"),
|
|
2078
2120
|
cell:
|
|
2079
2121
|
cells?.[c.key] ??
|
|
2080
2122
|
((item: TItem): ReactNode => {
|
|
@@ -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
|
|
2170
|
-
|
|
2171
|
-
|
|
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
|
-
{/*
|
|
2224
|
-
|
|
2225
|
-
|
|
2226
|
-
|
|
2227
|
-
|
|
2228
|
-
|
|
2229
|
-
|
|
2230
|
-
|
|
2231
|
-
|
|
2232
|
-
|
|
2233
|
-
|
|
2234
|
-
|
|
2235
|
-
|
|
2236
|
-
|
|
2237
|
-
|
|
2238
|
-
|
|
2239
|
-
|
|
2240
|
-
|
|
2241
|
-
|
|
2242
|
-
|
|
2243
|
-
|
|
2244
|
-
|
|
2245
|
-
|
|
2246
|
-
|
|
2247
|
-
|
|
2248
|
-
|
|
2249
|
-
|
|
2250
|
-
|
|
2251
|
-
|
|
2252
|
-
|
|
2253
|
-
|
|
2254
|
-
|
|
2255
|
-
|
|
2256
|
-
|
|
2257
|
-
|
|
2258
|
-
|
|
2259
|
-
|
|
2260
|
-
|
|
2261
|
-
|
|
2262
|
-
|
|
2263
|
-
|
|
2264
|
-
|
|
2265
|
-
|
|
2266
|
-
|
|
2267
|
-
|
|
2268
|
-
|
|
2269
|
-
|
|
2270
|
-
|
|
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
|
-
|
|
2338
|
-
|
|
2339
|
-
|
|
2340
|
-
|
|
2341
|
-
|
|
2342
|
-
|
|
2343
|
-
|
|
2344
|
-
|
|
2345
|
-
|
|
2346
|
-
|
|
2347
|
-
|
|
2348
|
-
|
|
2349
|
-
|
|
2350
|
-
|
|
2351
|
-
|
|
2352
|
-
|
|
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
|
-
|
|
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={
|
|
2363
|
-
onChange={(
|
|
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
|
-
|
|
2496
|
+
limit: n === pageSize ? null : n,
|
|
2367
2497
|
page: 1,
|
|
2368
|
-
})
|
|
2369
|
-
}
|
|
2370
|
-
options={
|
|
2371
|
-
|
|
2372
|
-
|
|
2373
|
-
|
|
2374
|
-
|
|
2375
|
-
|
|
2376
|
-
|
|
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
|
-
</
|
|
2450
|
-
</
|
|
2451
|
-
|
|
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={
|
|
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" &&
|
|
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(
|
|
@@ -2534,7 +2602,13 @@ export function ActionList<TInput, TItem>({
|
|
|
2534
2602
|
</TableCell>
|
|
2535
2603
|
)}
|
|
2536
2604
|
{derived.map((col) => (
|
|
2537
|
-
<TableCell
|
|
2605
|
+
<TableCell
|
|
2606
|
+
key={col.key}
|
|
2607
|
+
className={cn(
|
|
2608
|
+
"has-[>[role=switch]:only-child]:w-0 has-[>[data-slot=tooltip-trigger]:only-child>[role=switch]:only-child]:w-0",
|
|
2609
|
+
col.className,
|
|
2610
|
+
)}
|
|
2611
|
+
>
|
|
2538
2612
|
{col.cell(item)}
|
|
2539
2613
|
</TableCell>
|
|
2540
2614
|
))}
|
|
@@ -2543,14 +2617,56 @@ export function ActionList<TInput, TItem>({
|
|
|
2543
2617
|
className="w-px whitespace-nowrap text-right"
|
|
2544
2618
|
onClick={(e) => e.stopPropagation()}
|
|
2545
2619
|
>
|
|
2546
|
-
<RowActions>{rowActions(item)}</RowActions>
|
|
2620
|
+
<RowActions>{rowActions(item, listContext)}</RowActions>
|
|
2547
2621
|
</TableCell>
|
|
2548
2622
|
)}
|
|
2549
2623
|
</TableRow>
|
|
2550
2624
|
));
|
|
2551
|
-
const tableRows = renderTableRows(items);
|
|
2625
|
+
const tableRows = itemSpec === undefined ? renderTableRows(items) : null;
|
|
2552
2626
|
let content: ReactNode;
|
|
2553
|
-
if (
|
|
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) {
|
|
2554
2670
|
// Modo COMPOSIÇÃO: o layout é de quem compõe; a toolbar e os estados seguem daqui.
|
|
2555
2671
|
content = children(items, () => refetch());
|
|
2556
2672
|
} else if (activeView !== "table" && views?.[activeView] !== undefined) {
|
|
@@ -2597,7 +2713,10 @@ export function ActionList<TInput, TItem>({
|
|
|
2597
2713
|
</TableHead>
|
|
2598
2714
|
)}
|
|
2599
2715
|
{derived.map((col) => (
|
|
2600
|
-
<TableHead
|
|
2716
|
+
<TableHead
|
|
2717
|
+
key={col.key}
|
|
2718
|
+
className={cn(col.className, col.spec && "whitespace-normal")}
|
|
2719
|
+
>
|
|
2601
2720
|
{col.sortable === true ? (
|
|
2602
2721
|
<button
|
|
2603
2722
|
type="button"
|
|
@@ -2878,95 +2997,126 @@ export function ActionList<TInput, TItem>({
|
|
|
2878
2997
|
)}
|
|
2879
2998
|
</span>
|
|
2880
2999
|
) : null;
|
|
2881
|
-
|
|
2882
|
-
|
|
2883
|
-
|
|
2884
|
-
|
|
2885
|
-
|
|
2886
|
-
|
|
2887
|
-
|
|
2888
|
-
|
|
2889
|
-
|
|
2890
|
-
|
|
2891
|
-
|
|
2892
|
-
|
|
2893
|
-
|
|
2894
|
-
|
|
2895
|
-
|
|
2896
|
-
|
|
2897
|
-
|
|
2898
|
-
|
|
2899
|
-
|
|
2900
|
-
|
|
2901
|
-
|
|
2902
|
-
|
|
2903
|
-
|
|
2904
|
-
|
|
2905
|
-
|
|
2906
|
-
|
|
2907
|
-
|
|
2908
|
-
|
|
2909
|
-
|
|
2910
|
-
|
|
2911
|
-
|
|
2912
|
-
|
|
2913
|
-
|
|
2914
|
-
|
|
2915
|
-
|
|
2916
|
-
|
|
2917
|
-
|
|
2918
|
-
|
|
2919
|
-
|
|
2920
|
-
|
|
2921
|
-
|
|
2922
|
-
|
|
2923
|
-
|
|
2924
|
-
<PaginationItem key={n}>
|
|
2925
|
-
<PaginationLink
|
|
2926
|
-
page={n}
|
|
2927
|
-
isActive={n === state.page}
|
|
2928
|
-
size="sm"
|
|
2929
|
-
className="text-xs"
|
|
2930
|
-
onClick={() => goTo(n)}
|
|
2931
|
-
/>
|
|
2932
|
-
</PaginationItem>
|
|
2933
|
-
),
|
|
2934
|
-
)}
|
|
2935
|
-
<PaginationItem>
|
|
2936
|
-
<PaginationNext
|
|
2937
|
-
iconOnly
|
|
2938
|
-
size="icon-sm"
|
|
2939
|
-
disabled={state.page >= pages}
|
|
2940
|
-
onClick={() => goTo(state.page + 1)}
|
|
2941
|
-
/>
|
|
2942
|
-
</PaginationItem>
|
|
2943
|
-
</PaginationContent>
|
|
2944
|
-
</Pagination>
|
|
2945
|
-
)}
|
|
2946
|
-
{/* Com 1 página o pager some; o placeholder segura a coluna do meio. */}
|
|
2947
|
-
{pages <= 1 && <span />}
|
|
2948
|
-
<span className="justify-self-end whitespace-nowrap">
|
|
2949
|
-
{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}
|
|
2950
3043
|
</span>
|
|
2951
|
-
|
|
2952
|
-
|
|
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;
|
|
2953
3096
|
|
|
2954
3097
|
return (
|
|
2955
3098
|
<TooltipProvider>
|
|
2956
3099
|
<div
|
|
2957
3100
|
ref={rootRef}
|
|
2958
|
-
data-
|
|
3101
|
+
data-slot="action-list"
|
|
3102
|
+
data-action={actionProp?.name}
|
|
3103
|
+
data-source={source === undefined ? undefined : "external"}
|
|
2959
3104
|
data-layout={layout}
|
|
2960
3105
|
className={cn(
|
|
2961
3106
|
layout === "fill"
|
|
2962
|
-
? "flex h-full min-h-0 flex-1 flex-col gap-
|
|
2963
|
-
: "space-y-
|
|
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",
|
|
2964
3109
|
)}
|
|
2965
3110
|
>
|
|
2966
3111
|
{/* A lista é a folha da cadeia de altura: o que ela hospeda decide a própria. */}
|
|
2967
3112
|
<FillBoundary>
|
|
3113
|
+
{title !== undefined && (
|
|
3114
|
+
<Surface title={title} actions={collectionActions} />
|
|
3115
|
+
)}
|
|
2968
3116
|
{toolbar}
|
|
2969
|
-
{beforeContent
|
|
3117
|
+
{typeof beforeContent === "function"
|
|
3118
|
+
? beforeContent(effectiveInput)
|
|
3119
|
+
: beforeContent}
|
|
2970
3120
|
{selectionBar}
|
|
2971
3121
|
{/* Tabela e rodapé formam um bloco só: o `gap` da pilha separa a barra de
|
|
2972
3122
|
ferramentas do conteúdo, nunca o conteúdo do seu próprio rodapé. Duas
|
|
@@ -2975,7 +3125,7 @@ export function ActionList<TInput, TItem>({
|
|
|
2975
3125
|
tem altura própria e ela vira a altura do conteúdo. */}
|
|
2976
3126
|
<div
|
|
2977
3127
|
className={cn(
|
|
2978
|
-
"grid min-h-0 grid-rows-[1fr_auto]",
|
|
3128
|
+
"grid min-h-0 min-w-0 grid-cols-[minmax(0,1fr)] grid-rows-[1fr_auto]",
|
|
2979
3129
|
layout === "fill" && "flex-1",
|
|
2980
3130
|
)}
|
|
2981
3131
|
>
|
|
@@ -2986,7 +3136,7 @@ export function ActionList<TInput, TItem>({
|
|
|
2986
3136
|
<div
|
|
2987
3137
|
aria-busy={busy}
|
|
2988
3138
|
className={cn(
|
|
2989
|
-
"min-h-0",
|
|
3139
|
+
"min-h-0 min-w-0",
|
|
2990
3140
|
layout === "fill" && "overflow-auto",
|
|
2991
3141
|
busy && items.length > 0 && "pointer-events-none opacity-60",
|
|
2992
3142
|
)}
|
|
@@ -3111,12 +3261,83 @@ export function listParamsToState(
|
|
|
3111
3261
|
to: range?.[2] ?? params.get("to"),
|
|
3112
3262
|
view: params.get("view"),
|
|
3113
3263
|
columns: cols !== null && cols !== "" ? cols.split(",") : null,
|
|
3114
|
-
limit:
|
|
3115
|
-
|
|
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,
|
|
3116
3274
|
groupBy,
|
|
3117
3275
|
};
|
|
3118
3276
|
}
|
|
3119
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
|
+
|
|
3120
3341
|
/** Estado da toolbar → querystring ('' ou '?…'), omitindo defaults (URL limpa). */
|
|
3121
3342
|
export function listStateToParams(
|
|
3122
3343
|
state: ActionListState,
|
|
@@ -3155,8 +3376,10 @@ export function listStateToParams(
|
|
|
3155
3376
|
}
|
|
3156
3377
|
if (state.view !== null) qs.set("view", state.view);
|
|
3157
3378
|
if (state.columns !== null) qs.set("cols", state.columns.join(","));
|
|
3158
|
-
if (
|
|
3159
|
-
|
|
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));
|
|
3160
3383
|
if (
|
|
3161
3384
|
state.groupBy !== undefined &&
|
|
3162
3385
|
state.groupBy !== null &&
|