rl-core-front 0.15.5 → 0.15.6
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/package.json +1 -1
- package/src/components/ui/data-table.tsx +600 -250
- package/src/components/ui/table.tsx +30 -1
- package/src/hooks/use-column-visibility.ts +6 -1
- package/src/hooks/use-table-view.ts +54 -0
- package/src/i18n/messages/en.ts +4 -0
- package/src/i18n/messages/pt.ts +4 -0
- package/src/index.ts +2 -0
package/package.json
CHANGED
|
@@ -30,7 +30,7 @@ import {
|
|
|
30
30
|
SlidersHorizontal,
|
|
31
31
|
} from "lucide-react";
|
|
32
32
|
import type { JSX, PointerEvent as ReactPointerEvent } from "react";
|
|
33
|
-
import { useMemo, useRef, useState } from "react";
|
|
33
|
+
import { Fragment, useMemo, useRef, useState } from "react";
|
|
34
34
|
|
|
35
35
|
import { countConditions } from "#core/_utils/advanced-filter";
|
|
36
36
|
import { activeFilterCount } from "#core/_utils/filter";
|
|
@@ -42,11 +42,13 @@ import {
|
|
|
42
42
|
ColumnVisibilityOption,
|
|
43
43
|
} from "#core/components/ui/column-visibility-modal";
|
|
44
44
|
import { FilterSheet } from "#core/components/ui/filter-sheet";
|
|
45
|
+
import { SegmentedControl } from "#core/components/ui/segmented-control";
|
|
45
46
|
import { Spinner } from "#core/components/ui/spinner";
|
|
46
47
|
import {
|
|
47
48
|
Table,
|
|
48
49
|
TableBody,
|
|
49
50
|
TableCell,
|
|
51
|
+
TableFooter,
|
|
50
52
|
TableHead,
|
|
51
53
|
TableHeader,
|
|
52
54
|
TableRow,
|
|
@@ -54,6 +56,8 @@ import {
|
|
|
54
56
|
import { useI18n } from "#core/contexts";
|
|
55
57
|
import { useColumnVisibility } from "#core/hooks/use-column-visibility";
|
|
56
58
|
import type { FiltersState } from "#core/hooks/use-filters";
|
|
59
|
+
import type { TableView } from "#core/hooks/use-table-view";
|
|
60
|
+
import { useTableView } from "#core/hooks/use-table-view";
|
|
57
61
|
import { cn } from "#core/lib/utils";
|
|
58
62
|
|
|
59
63
|
export const dataTableFeatures = tableFeatures({
|
|
@@ -148,6 +152,66 @@ interface DataTableProps<T extends RowData> {
|
|
|
148
152
|
* gravada, e arrastar escreveria uma sequência que ninguém viu.
|
|
149
153
|
*/
|
|
150
154
|
onRowReorder?: (activeId: string, overId: string) => void;
|
|
155
|
+
/**
|
|
156
|
+
* Desenha a listagem em cartões, e não em linhas.
|
|
157
|
+
*
|
|
158
|
+
* Recebe as linhas da página atual, já ordenadas e filtradas, e devolve o
|
|
159
|
+
* que aparece no lugar da tabela. Com esta prop a barra ganha o alternador
|
|
160
|
+
* lista/cartões, e a escolha fica guardada por `storageKey` — dado que se vê
|
|
161
|
+
* (uma coleção, um catálogo de fotos) se compara olhando, e dado que se
|
|
162
|
+
* confere se compara lendo; a mesma listagem serve às duas coisas.
|
|
163
|
+
*
|
|
164
|
+
* Em cartões não há cabeçalho, logo não há por onde ordenar: a ordem
|
|
165
|
+
* escolhida na lista continua valendo, e o botão de colunas some — cartão
|
|
166
|
+
* não tem coluna para esconder.
|
|
167
|
+
*/
|
|
168
|
+
renderGrid?: (rows: T[]) => React.ReactNode;
|
|
169
|
+
/** Como a listagem abre na primeira visita, antes de alguém escolher. */
|
|
170
|
+
defaultView?: TableView;
|
|
171
|
+
/**
|
|
172
|
+
* A que seção cada linha pertence — sem isto, não há agrupamento.
|
|
173
|
+
*
|
|
174
|
+
* O grupo é sempre a chave primária da ordem: ordenar por uma coluna ordena
|
|
175
|
+
* **dentro** da seção. Do contrário, um clique no cabeçalho intercalaria as
|
|
176
|
+
* seções e o cabeçalho de grupo reapareceria a cada troca de valor — dez
|
|
177
|
+
* faixas onde existem duas.
|
|
178
|
+
*/
|
|
179
|
+
groupBy?: (row: T) => string;
|
|
180
|
+
/**
|
|
181
|
+
* O cabeçalho de cada seção: uma linha que atravessa a tabela inteira.
|
|
182
|
+
*
|
|
183
|
+
* Recebe a chave e as linhas da seção — é de onde saem contagem e total sem
|
|
184
|
+
* o consumidor precisar refazer a divisão que a tabela acabou de fazer. Sem
|
|
185
|
+
* esta prop o agrupamento só ordena, e nada é desenhado.
|
|
186
|
+
*/
|
|
187
|
+
renderGroupHeader?: (group: DataTableGroup<T>) => React.ReactNode;
|
|
188
|
+
/**
|
|
189
|
+
* A faixa de cada seção fecha e abre no clique.
|
|
190
|
+
*
|
|
191
|
+
* Fechada, a seção fica só na faixa — e é por isso que a faixa recebe
|
|
192
|
+
* `collapsed`: quem a desenha decide o que dizer quando as linhas não estão
|
|
193
|
+
* à vista (a contagem, em geral, que aberta é redundante). A escolha vale
|
|
194
|
+
* enquanto a listagem estiver na tela; não é gravada.
|
|
195
|
+
*/
|
|
196
|
+
collapsibleGroups?: boolean;
|
|
197
|
+
/**
|
|
198
|
+
* A ordem das seções.
|
|
199
|
+
*
|
|
200
|
+
* Chave que não aparecer aqui vai para o fim, na ordem em que apareceu nos
|
|
201
|
+
* dados. Sem a prop, a ordem é a de chegada — e "a de chegada" muda quando
|
|
202
|
+
* alguém ordena por uma coluna, por isso toda listagem com seções fixas
|
|
203
|
+
* (a receber / recebidas) declara esta lista.
|
|
204
|
+
*/
|
|
205
|
+
groupOrder?: string[];
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** Uma seção da listagem, como o cabeçalho de grupo a recebe. */
|
|
209
|
+
export interface DataTableGroup<T> {
|
|
210
|
+
key: string;
|
|
211
|
+
/** As linhas da seção — todas, mesmo com ela fechada. */
|
|
212
|
+
rows: T[];
|
|
213
|
+
/** Sempre `false` sem `collapsibleGroups`. */
|
|
214
|
+
collapsed: boolean;
|
|
151
215
|
}
|
|
152
216
|
|
|
153
217
|
/** Extras que uma coluna pode declarar em `meta`. */
|
|
@@ -159,6 +223,33 @@ export interface DataTableColumnMeta {
|
|
|
159
223
|
* É o caso da coluna de ações: sem isso o `<table>` a esticava.
|
|
160
224
|
*/
|
|
161
225
|
shrink?: boolean;
|
|
226
|
+
/**
|
|
227
|
+
* A coluna existe, mas não aparece até alguém ligá-la em "Colunas".
|
|
228
|
+
*
|
|
229
|
+
* Para o dado que a listagem tem e quase ninguém procura — a loja, o dia da
|
|
230
|
+
* compra — em tabela que já mostra o essencial. Vale só na primeira visita:
|
|
231
|
+
* a partir do momento em que alguém escolhe as colunas, a escolha guardada é
|
|
232
|
+
* que manda, e uma coluna nova nascendo escondida não mexe no que a pessoa
|
|
233
|
+
* já arrumou.
|
|
234
|
+
*/
|
|
235
|
+
defaultHidden?: boolean;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* O id que a coluna terá na tabela: o declarado, ou a chave do acessor.
|
|
240
|
+
*
|
|
241
|
+
* É o mesmo par que o TanStack usa para nomear a coluna, e precisa ser lido
|
|
242
|
+
* antes dela existir — o padrão de visibilidade entra na montagem da tabela.
|
|
243
|
+
*/
|
|
244
|
+
function columnId<T extends RowData>(
|
|
245
|
+
col: ColumnDef<DataTableFeatures, T, unknown>,
|
|
246
|
+
): string | undefined {
|
|
247
|
+
if (col.id) {
|
|
248
|
+
return col.id;
|
|
249
|
+
}
|
|
250
|
+
const acessor = (col as { accessorKey?: unknown }).accessorKey;
|
|
251
|
+
|
|
252
|
+
return typeof acessor === "string" ? acessor : undefined;
|
|
162
253
|
}
|
|
163
254
|
|
|
164
255
|
function columnLabel<T extends RowData>(
|
|
@@ -175,6 +266,85 @@ function columnLabel<T extends RowData>(
|
|
|
175
266
|
return id;
|
|
176
267
|
}
|
|
177
268
|
|
|
269
|
+
/**
|
|
270
|
+
* As linhas repartidas em seções, na ordem em que as seções saem.
|
|
271
|
+
*
|
|
272
|
+
* Estável de propósito: dentro da seção, as linhas mantêm a ordem que a tabela
|
|
273
|
+
* já lhes deu — a ordenação por coluna continua valendo, só que dentro de cada
|
|
274
|
+
* faixa. Seção declarada em `order` que não tiver nenhuma linha não vira faixa
|
|
275
|
+
* vazia: some, como o bloco vazio de uma listagem some.
|
|
276
|
+
*/
|
|
277
|
+
function groupRows<T, R extends { original: T }>(
|
|
278
|
+
rows: R[],
|
|
279
|
+
groupBy: (row: T) => string,
|
|
280
|
+
order?: string[],
|
|
281
|
+
): { key: string; rows: R[] }[] {
|
|
282
|
+
const buckets = new Map<string, R[]>();
|
|
283
|
+
|
|
284
|
+
rows.forEach((row) => {
|
|
285
|
+
const key = groupBy(row.original);
|
|
286
|
+
const bucket = buckets.get(key);
|
|
287
|
+
if (bucket) {
|
|
288
|
+
bucket.push(row);
|
|
289
|
+
} else {
|
|
290
|
+
buckets.set(key, [row]);
|
|
291
|
+
}
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
const declared = (order ?? []).filter((key) => buckets.has(key));
|
|
295
|
+
const rest = [...buckets.keys()].filter((key) => !declared.includes(key));
|
|
296
|
+
|
|
297
|
+
return [...declared, ...rest].map((key) => ({
|
|
298
|
+
key,
|
|
299
|
+
rows: buckets.get(key) ?? [],
|
|
300
|
+
}));
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* O corpo da tabela em seções, com o índice de onde cada uma começa.
|
|
305
|
+
*
|
|
306
|
+
* Uma seção só, sem faixa, quando não há agrupamento — assim o corpo se
|
|
307
|
+
* desenha do mesmo jeito nos dois casos, em vez de dois caminhos que precisam
|
|
308
|
+
* ser mantidos iguais. O `offset` é a posição da primeira linha da seção na
|
|
309
|
+
* lista visível: é dele que sai o índice que o arrasto usa para medir o
|
|
310
|
+
* deslocamento.
|
|
311
|
+
*/
|
|
312
|
+
function bodySections<T, R extends { original: T }>(
|
|
313
|
+
rows: R[],
|
|
314
|
+
groupBy: ((row: T) => string) | undefined,
|
|
315
|
+
order: string[] | undefined,
|
|
316
|
+
collapsedKeys: string[],
|
|
317
|
+
): {
|
|
318
|
+
key: string;
|
|
319
|
+
offset: number;
|
|
320
|
+
rows: R[];
|
|
321
|
+
header: DataTableGroup<T> | null;
|
|
322
|
+
}[] {
|
|
323
|
+
if (!groupBy) {
|
|
324
|
+
return [{ key: "", offset: 0, rows, header: null }];
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
let offset = 0;
|
|
328
|
+
|
|
329
|
+
return groupRows(rows, groupBy, order).map((group) => {
|
|
330
|
+
const collapsed = collapsedKeys.includes(group.key);
|
|
331
|
+
const section = {
|
|
332
|
+
key: group.key,
|
|
333
|
+
offset,
|
|
334
|
+
rows: collapsed ? [] : group.rows,
|
|
335
|
+
header: {
|
|
336
|
+
key: group.key,
|
|
337
|
+
rows: group.rows.map((row) => row.original),
|
|
338
|
+
collapsed,
|
|
339
|
+
},
|
|
340
|
+
};
|
|
341
|
+
|
|
342
|
+
offset += section.rows.length;
|
|
343
|
+
|
|
344
|
+
return section;
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
|
|
178
348
|
/**
|
|
179
349
|
* `w-px` + `whitespace-nowrap` é o jeito de fazer uma coluna de tabela encolher
|
|
180
350
|
* até o conteúdo: o navegador trata a largura mínima como o conteúdo e o espaço
|
|
@@ -207,9 +377,17 @@ export function DataTable<T extends RowData>({
|
|
|
207
377
|
emptyMessage,
|
|
208
378
|
hideFooter = false,
|
|
209
379
|
onRowReorder,
|
|
380
|
+
renderGrid,
|
|
381
|
+
defaultView = "list",
|
|
382
|
+
groupBy,
|
|
383
|
+
renderGroupHeader,
|
|
384
|
+
groupOrder,
|
|
385
|
+
collapsibleGroups = false,
|
|
210
386
|
}: DataTableProps<T>): JSX.Element {
|
|
211
387
|
const { t } = useI18n();
|
|
212
388
|
const [colsOpen, setColsOpen] = useState(false);
|
|
389
|
+
/** As seções fechadas, por chave. */
|
|
390
|
+
const [collapsedGroups, setCollapsedGroups] = useState<string[]>([]);
|
|
213
391
|
const [filtersOpen, setFiltersOpen] = useState(false);
|
|
214
392
|
/** A linha na mão e a linha embaixo do dedo, enquanto o arrasto dura. */
|
|
215
393
|
const [draggedRow, setDraggedRow] = useState<string | null>(null);
|
|
@@ -233,7 +411,22 @@ export function DataTable<T extends RowData>({
|
|
|
233
411
|
*/
|
|
234
412
|
const [dragHeight, setDragHeight] = useState(0);
|
|
235
413
|
|
|
236
|
-
const {
|
|
414
|
+
const { view, setView } = useTableView(storageKey, defaultView);
|
|
415
|
+
|
|
416
|
+
const ocultasPorPadrao = useMemo(
|
|
417
|
+
() =>
|
|
418
|
+
columns
|
|
419
|
+
.filter((column) => (column.meta as DataTableColumnMeta)?.defaultHidden)
|
|
420
|
+
.map((column) => columnId(column))
|
|
421
|
+
.filter((id): id is string => Boolean(id)),
|
|
422
|
+
[columns],
|
|
423
|
+
);
|
|
424
|
+
const emCartoes = Boolean(renderGrid) && view === "grid";
|
|
425
|
+
|
|
426
|
+
const { hiddenColumns, saveHiddenColumns } = useColumnVisibility(
|
|
427
|
+
storageKey,
|
|
428
|
+
ocultasPorPadrao,
|
|
429
|
+
);
|
|
237
430
|
const columnVisibility = useMemo<ColumnVisibilityState>(() => {
|
|
238
431
|
const v: ColumnVisibilityState = {};
|
|
239
432
|
hiddenColumns.forEach((id) => {
|
|
@@ -289,6 +482,17 @@ export function DataTable<T extends RowData>({
|
|
|
289
482
|
...(manualPagination ? { rowCount: rowCount ?? 0 } : {}),
|
|
290
483
|
});
|
|
291
484
|
|
|
485
|
+
/*
|
|
486
|
+
Linha de fechamento: existe só se alguma coluna visível declarar `footer`.
|
|
487
|
+
|
|
488
|
+
Vem do `columnDef` e não de uma última linha no `data` porque totalizador
|
|
489
|
+
não é registro — no corpo, ele ordenaria junto com os outros e apareceria no
|
|
490
|
+
meio da tabela à primeira troca de coluna.
|
|
491
|
+
*/
|
|
492
|
+
const temFechamento = table
|
|
493
|
+
.getAllColumns()
|
|
494
|
+
.some((c) => c.getIsVisible() && Boolean(c.columnDef.footer));
|
|
495
|
+
|
|
292
496
|
const hideable: ColumnVisibilityOption[] = table
|
|
293
497
|
.getAllColumns()
|
|
294
498
|
.filter((c) => c.getCanHide())
|
|
@@ -312,7 +516,30 @@ export function DataTable<T extends RowData>({
|
|
|
312
516
|
A coluna da alça continua no lugar para a tabela não mudar de largura — só
|
|
313
517
|
o botão fica inerte, e o `title` diz o motivo.
|
|
314
518
|
*/
|
|
315
|
-
const canReorder =
|
|
519
|
+
const canReorder =
|
|
520
|
+
reorderable && (table.state.sorting ?? []).length === 0 && !groupBy;
|
|
521
|
+
|
|
522
|
+
/*
|
|
523
|
+
A lista já repartida em seções, achatada de volta numa sequência.
|
|
524
|
+
|
|
525
|
+
Achatada, e não desenhada como uma tabela por seção: seção que vira tabela
|
|
526
|
+
própria mede as próprias linhas, e a mesma coluna acaba com uma largura em
|
|
527
|
+
cada faixa. Uma tabela só é o que garante que a coluna vale o mesmo de cima
|
|
528
|
+
a baixo — sem largura fixa em lugar nenhum.
|
|
529
|
+
*/
|
|
530
|
+
const sections = bodySections(
|
|
531
|
+
table.getRowModel().rows,
|
|
532
|
+
groupBy,
|
|
533
|
+
groupOrder,
|
|
534
|
+
collapsedGroups,
|
|
535
|
+
);
|
|
536
|
+
|
|
537
|
+
const toggleGroup = (key: string): void =>
|
|
538
|
+
setCollapsedGroups((atual) =>
|
|
539
|
+
atual.includes(key)
|
|
540
|
+
? atual.filter((fechada) => fechada !== key)
|
|
541
|
+
: [...atual, key],
|
|
542
|
+
);
|
|
316
543
|
const colSpan =
|
|
317
544
|
(table.getVisibleLeafColumns().length || 1) + (reorderable ? 1 : 0);
|
|
318
545
|
|
|
@@ -405,6 +632,18 @@ export function DataTable<T extends RowData>({
|
|
|
405
632
|
};
|
|
406
633
|
|
|
407
634
|
const filtersEnabled = Boolean(filters);
|
|
635
|
+
const columnsButton =
|
|
636
|
+
Boolean(storageKey) && hideable.length > 0 && !emCartoes;
|
|
637
|
+
/*
|
|
638
|
+
A barra só existe se tiver o que mostrar.
|
|
639
|
+
|
|
640
|
+
Vazia, ela continuava ocupando os 12px do `mb-3` e empurrava a tabela para
|
|
641
|
+
baixo — a listagem sem toolbar, sem filtro e sem escolha de colunas ficava
|
|
642
|
+
com um vão maior que o das outras, sem nada que o explicasse.
|
|
643
|
+
*/
|
|
644
|
+
const temBarra = Boolean(
|
|
645
|
+
toolbar || toolbarEnd || renderGrid || filtersEnabled || columnsButton,
|
|
646
|
+
);
|
|
408
647
|
const activeFilters = !filters
|
|
409
648
|
? 0
|
|
410
649
|
: filters.mode === "advanced"
|
|
@@ -420,6 +659,82 @@ export function DataTable<T extends RowData>({
|
|
|
420
659
|
emptyMessage ??
|
|
421
660
|
(activeFilters > 0 ? t("table.emptyFiltered") : t("table.empty"));
|
|
422
661
|
|
|
662
|
+
/*
|
|
663
|
+
Três blocos, e a ordem tem motivo: quantos são à esquerda, para onde ir no
|
|
664
|
+
meio, e o tamanho da página à direita — o controle que se mexe uma vez fica
|
|
665
|
+
na ponta, e a navegação, que se usa o tempo todo, no centro. Em tela estreita
|
|
666
|
+
eles empilham em vez de espremer.
|
|
667
|
+
|
|
668
|
+
Fora do JSX da tabela porque a mesma navegação serve à lista e aos cartões:
|
|
669
|
+
o formato muda o que está acima dele, não em que página se está.
|
|
670
|
+
*/
|
|
671
|
+
const rodape = (
|
|
672
|
+
<div className="flex flex-col items-center gap-3 border-t border-border px-4 py-3 text-sm text-muted-foreground sm:flex-row sm:justify-between">
|
|
673
|
+
<span className="sm:flex-1">
|
|
674
|
+
{total} {t("table.results")}
|
|
675
|
+
</span>
|
|
676
|
+
|
|
677
|
+
<div className="flex items-center gap-1">
|
|
678
|
+
<Button
|
|
679
|
+
variant="ghost"
|
|
680
|
+
size="icon"
|
|
681
|
+
aria-label={t("table.previousPage")}
|
|
682
|
+
disabled={!table.getCanPreviousPage()}
|
|
683
|
+
onClick={() => table.previousPage()}
|
|
684
|
+
>
|
|
685
|
+
<ChevronLeft className="h-4 w-4" />
|
|
686
|
+
</Button>
|
|
687
|
+
|
|
688
|
+
{pageRange(pageIndex, pageCount).map((item, posicao) =>
|
|
689
|
+
item === "gap" ? (
|
|
690
|
+
// O buraco não é botão: `key` pela posição porque duas elipses
|
|
691
|
+
// são indistinguíveis entre si.
|
|
692
|
+
<span key={`gap-${posicao}`} className="px-1 text-muted-foreground">
|
|
693
|
+
…
|
|
694
|
+
</span>
|
|
695
|
+
) : (
|
|
696
|
+
<Button
|
|
697
|
+
key={item}
|
|
698
|
+
variant={item === pageIndex + 1 ? "default" : "ghost"}
|
|
699
|
+
size="icon"
|
|
700
|
+
// Quadrado de 40px como base, mas cresce com o número: a
|
|
701
|
+
// página 128 não pode vazar do botão.
|
|
702
|
+
className="w-auto min-w-10 px-2 tabular-nums"
|
|
703
|
+
aria-current={item === pageIndex + 1 ? "page" : undefined}
|
|
704
|
+
onClick={() => table.setPageIndex(item - 1)}
|
|
705
|
+
>
|
|
706
|
+
{item}
|
|
707
|
+
</Button>
|
|
708
|
+
),
|
|
709
|
+
)}
|
|
710
|
+
|
|
711
|
+
<Button
|
|
712
|
+
variant="ghost"
|
|
713
|
+
size="icon"
|
|
714
|
+
aria-label={t("table.nextPage")}
|
|
715
|
+
disabled={!table.getCanNextPage()}
|
|
716
|
+
onClick={() => table.nextPage()}
|
|
717
|
+
>
|
|
718
|
+
<ChevronRight className="h-4 w-4" />
|
|
719
|
+
</Button>
|
|
720
|
+
</div>
|
|
721
|
+
|
|
722
|
+
<div className="sm:flex-1 sm:text-right">
|
|
723
|
+
<select
|
|
724
|
+
value={pageSize}
|
|
725
|
+
onChange={(e) => table.setPageSize(Number(e.target.value))}
|
|
726
|
+
className="h-9 rounded-md border border-input bg-transparent px-2 text-sm"
|
|
727
|
+
>
|
|
728
|
+
{pageSizeOptions.map((n) => (
|
|
729
|
+
<option key={n} value={n}>
|
|
730
|
+
{n} {t("table.perPage")}
|
|
731
|
+
</option>
|
|
732
|
+
))}
|
|
733
|
+
</select>
|
|
734
|
+
</div>
|
|
735
|
+
</div>
|
|
736
|
+
);
|
|
737
|
+
|
|
423
738
|
return (
|
|
424
739
|
<div>
|
|
425
740
|
{/*
|
|
@@ -431,272 +746,307 @@ export function DataTable<T extends RowData>({
|
|
|
431
746
|
`min-w-0` é o par obrigatório disso — filho de flex não encolhe abaixo
|
|
432
747
|
do próprio conteúdo sem ele, e o `flex-wrap` sozinho não bastaria.
|
|
433
748
|
*/}
|
|
434
|
-
|
|
435
|
-
<div className="
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
749
|
+
{temBarra && (
|
|
750
|
+
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
|
|
751
|
+
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
|
752
|
+
{toolbar}
|
|
753
|
+
</div>
|
|
754
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
755
|
+
{renderGrid && (
|
|
756
|
+
<SegmentedControl
|
|
757
|
+
label={t("table.view")}
|
|
758
|
+
value={view}
|
|
759
|
+
onValueChange={(next) => setView(next as TableView)}
|
|
760
|
+
options={[
|
|
761
|
+
{ value: "list", label: t("table.viewList") },
|
|
762
|
+
{ value: "grid", label: t("table.viewGrid") },
|
|
763
|
+
]}
|
|
764
|
+
/>
|
|
765
|
+
)}
|
|
766
|
+
{toolbarEnd}
|
|
767
|
+
{/* Filtro antes de colunas: filtrar é a ação do dia a dia, escolher
|
|
441
768
|
coluna se faz uma vez e a tela guarda. */}
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
769
|
+
{filtersEnabled && (
|
|
770
|
+
<Button
|
|
771
|
+
variant="outline"
|
|
772
|
+
size="sm"
|
|
773
|
+
onClick={() => setFiltersOpen(true)}
|
|
774
|
+
>
|
|
775
|
+
<Filter className="h-4 w-4" />
|
|
776
|
+
{t("filters.button")}
|
|
777
|
+
{activeFilters > 0 && (
|
|
778
|
+
<span className="ml-1 rounded-full bg-primary px-1.5 text-xs text-primary-foreground">
|
|
779
|
+
{activeFilters}
|
|
780
|
+
</span>
|
|
781
|
+
)}
|
|
782
|
+
</Button>
|
|
783
|
+
)}
|
|
784
|
+
{columnsButton && (
|
|
785
|
+
<Button
|
|
786
|
+
variant="outline"
|
|
787
|
+
size="sm"
|
|
788
|
+
onClick={() => setColsOpen(true)}
|
|
789
|
+
>
|
|
790
|
+
<SlidersHorizontal className="h-4 w-4" />
|
|
791
|
+
{t("table.columnsButton")}
|
|
792
|
+
</Button>
|
|
793
|
+
)}
|
|
794
|
+
</div>
|
|
795
|
+
</div>
|
|
796
|
+
)}
|
|
797
|
+
|
|
798
|
+
{emCartoes && renderGrid ? (
|
|
799
|
+
<div className="flex flex-col gap-3">
|
|
800
|
+
{loading && (
|
|
801
|
+
<Card className="p-10">
|
|
802
|
+
<Spinner className="mx-auto" />
|
|
803
|
+
</Card>
|
|
456
804
|
)}
|
|
457
|
-
{
|
|
458
|
-
<
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
onClick={() => setColsOpen(true)}
|
|
462
|
-
>
|
|
463
|
-
<SlidersHorizontal className="h-4 w-4" />
|
|
464
|
-
{t("table.columnsButton")}
|
|
465
|
-
</Button>
|
|
805
|
+
{!loading && table.getRowModel().rows.length === 0 && (
|
|
806
|
+
<Card className="p-10 text-center text-muted-foreground">
|
|
807
|
+
{emptyText}
|
|
808
|
+
</Card>
|
|
466
809
|
)}
|
|
467
|
-
|
|
468
|
-
|
|
810
|
+
{!loading &&
|
|
811
|
+
table.getRowModel().rows.length > 0 &&
|
|
812
|
+
renderGrid(table.getRowModel().rows.map((row) => row.original))}
|
|
469
813
|
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
<
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
814
|
+
{/* O rodapé ganha moldura própria: solto sobre o fundo, a navegação
|
|
815
|
+
ficaria pendurada abaixo dos cartões, sem nada que a segure. */}
|
|
816
|
+
{!semRodape && <Card>{rodape}</Card>}
|
|
817
|
+
</div>
|
|
818
|
+
) : (
|
|
819
|
+
<Card>
|
|
820
|
+
<Table>
|
|
821
|
+
<TableHeader>
|
|
822
|
+
{table.getHeaderGroups().map((hg) => (
|
|
823
|
+
<TableRow key={hg.id}>
|
|
824
|
+
{reorderable && (
|
|
825
|
+
<TableHead className="w-px">
|
|
826
|
+
<span className="sr-only">{t("table.reorder")}</span>
|
|
827
|
+
</TableHead>
|
|
828
|
+
)}
|
|
829
|
+
{hg.headers.map((header) => {
|
|
830
|
+
const canSort = header.column.getCanSort();
|
|
831
|
+
const sorted = header.column.getIsSorted();
|
|
832
|
+
return (
|
|
833
|
+
<TableHead
|
|
834
|
+
key={header.id}
|
|
835
|
+
className={shrinkClass(header.column.columnDef)}
|
|
836
|
+
>
|
|
837
|
+
{header.isPlaceholder ? null : canSort ? (
|
|
838
|
+
<button
|
|
839
|
+
className="flex items-center gap-1 hover:text-foreground"
|
|
840
|
+
onClick={header.column.getToggleSortingHandler()}
|
|
841
|
+
>
|
|
842
|
+
{flexRender(
|
|
843
|
+
header.column.columnDef.header,
|
|
844
|
+
header.getContext(),
|
|
845
|
+
)}
|
|
846
|
+
{sorted === "asc" ? (
|
|
847
|
+
<ArrowUp className="h-3.5 w-3.5 text-primary" />
|
|
848
|
+
) : sorted === "desc" ? (
|
|
849
|
+
<ArrowDown className="h-3.5 w-3.5 text-primary" />
|
|
850
|
+
) : (
|
|
851
|
+
// Seta dupla apagada na coluna livre e seta única
|
|
852
|
+
// em destaque na que ordena: sem a cor, achar a
|
|
853
|
+
// coluna ativa exige comparar o desenho de todas.
|
|
854
|
+
<ArrowUpDown className="h-3.5 w-3.5 opacity-40" />
|
|
855
|
+
)}
|
|
856
|
+
</button>
|
|
857
|
+
) : (
|
|
858
|
+
flexRender(
|
|
494
859
|
header.column.columnDef.header,
|
|
495
860
|
header.getContext(),
|
|
496
|
-
)
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
861
|
+
)
|
|
862
|
+
)}
|
|
863
|
+
</TableHead>
|
|
864
|
+
);
|
|
865
|
+
})}
|
|
866
|
+
</TableRow>
|
|
867
|
+
))}
|
|
868
|
+
</TableHeader>
|
|
869
|
+
<TableBody>
|
|
870
|
+
{loading && (
|
|
871
|
+
<TableRow>
|
|
872
|
+
<TableCell colSpan={colSpan} className="h-24 text-center">
|
|
873
|
+
<Spinner className="mx-auto" />
|
|
874
|
+
</TableCell>
|
|
875
|
+
</TableRow>
|
|
876
|
+
)}
|
|
877
|
+
{!loading && table.getRowModel().rows.length === 0 && (
|
|
878
|
+
<TableRow>
|
|
879
|
+
<TableCell
|
|
880
|
+
colSpan={colSpan}
|
|
881
|
+
className="h-24 text-center text-muted-foreground"
|
|
882
|
+
>
|
|
883
|
+
{emptyText}
|
|
884
|
+
</TableCell>
|
|
885
|
+
</TableRow>
|
|
886
|
+
)}
|
|
887
|
+
{!loading &&
|
|
888
|
+
sections.map((section) => (
|
|
889
|
+
<Fragment key={section.key}>
|
|
890
|
+
{section.header && renderGroupHeader && (
|
|
891
|
+
<TableRow className="hover:bg-transparent">
|
|
892
|
+
{/* Uma célula atravessando a tabela: é o que faz a
|
|
893
|
+
faixa parecer cabeçalho, e não registro sem
|
|
894
|
+
dados. */}
|
|
895
|
+
<TableCell
|
|
896
|
+
colSpan={colSpan}
|
|
897
|
+
className="bg-muted/40 py-2"
|
|
898
|
+
>
|
|
899
|
+
{collapsibleGroups ? (
|
|
900
|
+
<button
|
|
901
|
+
type="button"
|
|
902
|
+
className="flex w-full items-center gap-3 text-left"
|
|
903
|
+
aria-expanded={!section.header.collapsed}
|
|
904
|
+
onClick={() => toggleGroup(section.key)}
|
|
905
|
+
>
|
|
906
|
+
<ChevronRight
|
|
907
|
+
className={cn(
|
|
908
|
+
"h-3.5 w-3.5 flex-none text-muted-foreground transition-transform",
|
|
909
|
+
!section.header.collapsed && "rotate-90",
|
|
910
|
+
)}
|
|
911
|
+
/>
|
|
912
|
+
{/* O conteúdo ocupa o resto: é o que deixa o
|
|
913
|
+
total encostar na borda direita, como
|
|
914
|
+
encostaria fora do botão. */}
|
|
915
|
+
<span className="min-w-0 flex-1">
|
|
916
|
+
{renderGroupHeader(section.header)}
|
|
917
|
+
</span>
|
|
918
|
+
</button>
|
|
501
919
|
) : (
|
|
502
|
-
|
|
503
|
-
// em destaque na que ordena: sem a cor, achar a
|
|
504
|
-
// coluna ativa exige comparar o desenho de todas.
|
|
505
|
-
<ArrowUpDown className="h-3.5 w-3.5 opacity-40" />
|
|
920
|
+
renderGroupHeader(section.header)
|
|
506
921
|
)}
|
|
507
|
-
</
|
|
508
|
-
|
|
509
|
-
flexRender(
|
|
510
|
-
header.column.columnDef.header,
|
|
511
|
-
header.getContext(),
|
|
512
|
-
)
|
|
513
|
-
)}
|
|
514
|
-
</TableHead>
|
|
515
|
-
);
|
|
516
|
-
})}
|
|
517
|
-
</TableRow>
|
|
518
|
-
))}
|
|
519
|
-
</TableHeader>
|
|
520
|
-
<TableBody>
|
|
521
|
-
{loading && (
|
|
522
|
-
<TableRow>
|
|
523
|
-
<TableCell colSpan={colSpan} className="h-24 text-center">
|
|
524
|
-
<Spinner className="mx-auto" />
|
|
525
|
-
</TableCell>
|
|
526
|
-
</TableRow>
|
|
527
|
-
)}
|
|
528
|
-
{!loading && table.getRowModel().rows.length === 0 && (
|
|
529
|
-
<TableRow>
|
|
530
|
-
<TableCell
|
|
531
|
-
colSpan={colSpan}
|
|
532
|
-
className="h-24 text-center text-muted-foreground"
|
|
533
|
-
>
|
|
534
|
-
{emptyText}
|
|
535
|
-
</TableCell>
|
|
536
|
-
</TableRow>
|
|
537
|
-
)}
|
|
538
|
-
{!loading &&
|
|
539
|
-
table.getRowModel().rows.map((row, index) => {
|
|
540
|
-
const dragging = draggedRow === row.id;
|
|
541
|
-
const shift = dragging ? 0 : rowShift(index);
|
|
542
|
-
|
|
543
|
-
return (
|
|
544
|
-
<TableRow
|
|
545
|
-
key={row.id}
|
|
546
|
-
data-row-id={row.id}
|
|
547
|
-
className={cn(
|
|
548
|
-
// A linha viaja com o dedo, destacada do resto da tabela —
|
|
549
|
-
// é o que diz que o arrasto pegou, e qual linha pegou.
|
|
550
|
-
dragging &&
|
|
551
|
-
"relative z-20 bg-card shadow-xl ring-1 ring-primary/50",
|
|
552
|
-
draggedRow !== null && "select-none",
|
|
553
|
-
/*
|
|
554
|
-
A transição existe só enquanto o arrasto dura, e só nas
|
|
555
|
-
linhas paradas: na que segue o dedo ela viraria atraso,
|
|
556
|
-
e depois de soltar ela animaria a volta ao zero por
|
|
557
|
-
cima da lista já reordenada — dois movimentos ao mesmo
|
|
558
|
-
tempo, que é o tremor que se vê ao largar a linha.
|
|
559
|
-
*/
|
|
560
|
-
draggedRow !== null &&
|
|
561
|
-
!dragging &&
|
|
562
|
-
"transition-transform duration-200",
|
|
922
|
+
</TableCell>
|
|
923
|
+
</TableRow>
|
|
563
924
|
)}
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
: undefined
|
|
575
|
-
}
|
|
576
|
-
>
|
|
577
|
-
{reorderable && (
|
|
578
|
-
<TableCell className="w-px pr-0">
|
|
579
|
-
<button
|
|
580
|
-
type="button"
|
|
581
|
-
aria-label={t("table.reorder")}
|
|
582
|
-
title={
|
|
583
|
-
canReorder
|
|
584
|
-
? t("table.reorder")
|
|
585
|
-
: t("table.reorderSorted")
|
|
586
|
-
}
|
|
587
|
-
disabled={!canReorder}
|
|
588
|
-
// `touch-none` é o que impede a página de rolar embaixo
|
|
589
|
-
// do dedo enquanto a linha viaja.
|
|
925
|
+
|
|
926
|
+
{section.rows.map((row, indexNaSecao) => {
|
|
927
|
+
const index = section.offset + indexNaSecao;
|
|
928
|
+
const dragging = draggedRow === row.id;
|
|
929
|
+
const shift = dragging ? 0 : rowShift(index);
|
|
930
|
+
|
|
931
|
+
return (
|
|
932
|
+
<TableRow
|
|
933
|
+
key={row.id}
|
|
934
|
+
data-row-id={row.id}
|
|
590
935
|
className={cn(
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
936
|
+
// A linha viaja com o dedo, destacada do resto da tabela —
|
|
937
|
+
// é o que diz que o arrasto pegou, e qual linha pegou.
|
|
938
|
+
dragging &&
|
|
939
|
+
"relative z-20 bg-card shadow-xl ring-1 ring-primary/50",
|
|
940
|
+
draggedRow !== null && "select-none",
|
|
941
|
+
/*
|
|
942
|
+
A transição existe só enquanto o arrasto dura, e só nas
|
|
943
|
+
linhas paradas: na que segue o dedo ela viraria atraso,
|
|
944
|
+
e depois de soltar ela animaria a volta ao zero por
|
|
945
|
+
cima da lista já reordenada — dois movimentos ao mesmo
|
|
946
|
+
tempo, que é o tremor que se vê ao largar a linha.
|
|
947
|
+
*/
|
|
948
|
+
draggedRow !== null &&
|
|
949
|
+
!dragging &&
|
|
950
|
+
"transition-transform duration-200",
|
|
595
951
|
)}
|
|
596
|
-
|
|
597
|
-
|
|
952
|
+
style={
|
|
953
|
+
dragging
|
|
954
|
+
? {
|
|
955
|
+
// O `scale` é o "levantou da mesa": a linha na mão
|
|
956
|
+
// fica maior que as paradas, como o card que sai
|
|
957
|
+
// da coluna no quadro.
|
|
958
|
+
transform: `translateY(${dragOffset}px) scale(1.01)`,
|
|
959
|
+
}
|
|
960
|
+
: shift !== 0
|
|
961
|
+
? { transform: `translateY(${shift}px)` }
|
|
962
|
+
: undefined
|
|
598
963
|
}
|
|
599
|
-
onPointerMove={canReorder ? moveRowDrag : undefined}
|
|
600
|
-
onPointerUp={canReorder ? endRowDrag : undefined}
|
|
601
|
-
onPointerCancel={canReorder ? endRowDrag : undefined}
|
|
602
964
|
>
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
965
|
+
{reorderable && (
|
|
966
|
+
<TableCell className="w-px pr-0">
|
|
967
|
+
<button
|
|
968
|
+
type="button"
|
|
969
|
+
aria-label={t("table.reorder")}
|
|
970
|
+
title={
|
|
971
|
+
canReorder
|
|
972
|
+
? t("table.reorder")
|
|
973
|
+
: groupBy
|
|
974
|
+
? t("table.reorderGrouped")
|
|
975
|
+
: t("table.reorderSorted")
|
|
976
|
+
}
|
|
977
|
+
disabled={!canReorder}
|
|
978
|
+
// `touch-none` é o que impede a página de rolar embaixo
|
|
979
|
+
// do dedo enquanto a linha viaja.
|
|
980
|
+
className={cn(
|
|
981
|
+
"flex touch-none items-center text-muted-foreground",
|
|
982
|
+
canReorder
|
|
983
|
+
? "cursor-grab active:cursor-grabbing hover:text-foreground"
|
|
984
|
+
: "cursor-not-allowed opacity-30",
|
|
985
|
+
)}
|
|
986
|
+
onPointerDown={
|
|
987
|
+
canReorder ? startRowDrag(row.id) : undefined
|
|
988
|
+
}
|
|
989
|
+
onPointerMove={
|
|
990
|
+
canReorder ? moveRowDrag : undefined
|
|
991
|
+
}
|
|
992
|
+
onPointerUp={
|
|
993
|
+
canReorder ? endRowDrag : undefined
|
|
994
|
+
}
|
|
995
|
+
onPointerCancel={
|
|
996
|
+
canReorder ? endRowDrag : undefined
|
|
997
|
+
}
|
|
998
|
+
>
|
|
999
|
+
<GripVertical className="h-4 w-4" />
|
|
1000
|
+
</button>
|
|
1001
|
+
</TableCell>
|
|
1002
|
+
)}
|
|
1003
|
+
{row.getVisibleCells().map((cell) => (
|
|
1004
|
+
<TableCell
|
|
1005
|
+
key={cell.id}
|
|
1006
|
+
className={shrinkClass(cell.column.columnDef)}
|
|
1007
|
+
>
|
|
1008
|
+
{flexRender(
|
|
1009
|
+
cell.column.columnDef.cell,
|
|
1010
|
+
cell.getContext(),
|
|
1011
|
+
)}
|
|
1012
|
+
</TableCell>
|
|
1013
|
+
))}
|
|
1014
|
+
</TableRow>
|
|
1015
|
+
);
|
|
1016
|
+
})}
|
|
1017
|
+
</Fragment>
|
|
1018
|
+
))}
|
|
1019
|
+
</TableBody>
|
|
1020
|
+
|
|
1021
|
+
{temFechamento && !loading && (
|
|
1022
|
+
<TableFooter>
|
|
1023
|
+
{table.getFooterGroups().map((grupo) => (
|
|
1024
|
+
<TableRow key={grupo.id}>
|
|
1025
|
+
{/* A coluna da alça não tem fechamento, mas precisa da
|
|
1026
|
+
célula: sem ela a linha inteira sobe uma casa. */}
|
|
1027
|
+
{reorderable && <TableCell className="w-px" />}
|
|
1028
|
+
{grupo.headers.map((header) => (
|
|
608
1029
|
<TableCell
|
|
609
|
-
key={
|
|
610
|
-
className={shrinkClass(
|
|
1030
|
+
key={header.id}
|
|
1031
|
+
className={shrinkClass(header.column.columnDef)}
|
|
611
1032
|
>
|
|
612
|
-
{
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
1033
|
+
{header.isPlaceholder
|
|
1034
|
+
? null
|
|
1035
|
+
: flexRender(
|
|
1036
|
+
header.column.columnDef.footer,
|
|
1037
|
+
header.getContext(),
|
|
1038
|
+
)}
|
|
616
1039
|
</TableCell>
|
|
617
1040
|
))}
|
|
618
1041
|
</TableRow>
|
|
619
|
-
);
|
|
620
|
-
})}
|
|
621
|
-
</TableBody>
|
|
622
|
-
</Table>
|
|
623
|
-
|
|
624
|
-
{/*
|
|
625
|
-
Três blocos, e a ordem tem motivo: quantos são à esquerda, para onde
|
|
626
|
-
ir no meio, e o tamanho da página à direita — o controle que se mexe
|
|
627
|
-
uma vez fica na ponta, e a navegação, que se usa o tempo todo, no
|
|
628
|
-
centro. Em tela estreita eles empilham em vez de espremer.
|
|
629
|
-
*/}
|
|
630
|
-
{!semRodape && (
|
|
631
|
-
<div className="flex flex-col items-center gap-3 border-t border-border px-4 py-3 text-sm text-muted-foreground sm:flex-row sm:justify-between">
|
|
632
|
-
<span className="sm:flex-1">
|
|
633
|
-
{total} {t("table.results")}
|
|
634
|
-
</span>
|
|
635
|
-
|
|
636
|
-
<div className="flex items-center gap-1">
|
|
637
|
-
<Button
|
|
638
|
-
variant="ghost"
|
|
639
|
-
size="icon"
|
|
640
|
-
aria-label={t("table.previousPage")}
|
|
641
|
-
disabled={!table.getCanPreviousPage()}
|
|
642
|
-
onClick={() => table.previousPage()}
|
|
643
|
-
>
|
|
644
|
-
<ChevronLeft className="h-4 w-4" />
|
|
645
|
-
</Button>
|
|
646
|
-
|
|
647
|
-
{pageRange(pageIndex, pageCount).map((item, posicao) =>
|
|
648
|
-
item === "gap" ? (
|
|
649
|
-
// O buraco não é botão: `key` pela posição porque duas elipses
|
|
650
|
-
// são indistinguíveis entre si.
|
|
651
|
-
<span
|
|
652
|
-
key={`gap-${posicao}`}
|
|
653
|
-
className="px-1 text-muted-foreground"
|
|
654
|
-
>
|
|
655
|
-
…
|
|
656
|
-
</span>
|
|
657
|
-
) : (
|
|
658
|
-
<Button
|
|
659
|
-
key={item}
|
|
660
|
-
variant={item === pageIndex + 1 ? "default" : "ghost"}
|
|
661
|
-
size="icon"
|
|
662
|
-
// Quadrado de 40px como base, mas cresce com o número: a
|
|
663
|
-
// página 128 não pode vazar do botão.
|
|
664
|
-
className="w-auto min-w-10 px-2 tabular-nums"
|
|
665
|
-
aria-current={item === pageIndex + 1 ? "page" : undefined}
|
|
666
|
-
onClick={() => table.setPageIndex(item - 1)}
|
|
667
|
-
>
|
|
668
|
-
{item}
|
|
669
|
-
</Button>
|
|
670
|
-
),
|
|
671
|
-
)}
|
|
672
|
-
|
|
673
|
-
<Button
|
|
674
|
-
variant="ghost"
|
|
675
|
-
size="icon"
|
|
676
|
-
aria-label={t("table.nextPage")}
|
|
677
|
-
disabled={!table.getCanNextPage()}
|
|
678
|
-
onClick={() => table.nextPage()}
|
|
679
|
-
>
|
|
680
|
-
<ChevronRight className="h-4 w-4" />
|
|
681
|
-
</Button>
|
|
682
|
-
</div>
|
|
683
|
-
|
|
684
|
-
<div className="sm:flex-1 sm:text-right">
|
|
685
|
-
<select
|
|
686
|
-
value={pageSize}
|
|
687
|
-
onChange={(e) => table.setPageSize(Number(e.target.value))}
|
|
688
|
-
className="h-9 rounded-md border border-input bg-transparent px-2 text-sm"
|
|
689
|
-
>
|
|
690
|
-
{pageSizeOptions.map((n) => (
|
|
691
|
-
<option key={n} value={n}>
|
|
692
|
-
{n} {t("table.perPage")}
|
|
693
|
-
</option>
|
|
694
1042
|
))}
|
|
695
|
-
</
|
|
696
|
-
|
|
697
|
-
</
|
|
698
|
-
|
|
699
|
-
|
|
1043
|
+
</TableFooter>
|
|
1044
|
+
)}
|
|
1045
|
+
</Table>
|
|
1046
|
+
|
|
1047
|
+
{!semRodape && rodape}
|
|
1048
|
+
</Card>
|
|
1049
|
+
)}
|
|
700
1050
|
|
|
701
1051
|
{storageKey && (
|
|
702
1052
|
<ColumnVisibilityModal
|
|
@@ -36,6 +36,27 @@ const TableBody = React.forwardRef<
|
|
|
36
36
|
));
|
|
37
37
|
TableBody.displayName = "TableBody";
|
|
38
38
|
|
|
39
|
+
/**
|
|
40
|
+
* A linha de fechamento da tabela: total, saldo, contagem.
|
|
41
|
+
*
|
|
42
|
+
* `tfoot` e não uma última linha do corpo: ela não é registro — não ordena
|
|
43
|
+
* junto, não entra na contagem e continua embaixo quando a ordenação muda.
|
|
44
|
+
*/
|
|
45
|
+
const TableFooter = React.forwardRef<
|
|
46
|
+
HTMLTableSectionElement,
|
|
47
|
+
React.HTMLAttributes<HTMLTableSectionElement>
|
|
48
|
+
>(({ className, ...props }, ref) => (
|
|
49
|
+
<tfoot
|
|
50
|
+
ref={ref}
|
|
51
|
+
className={cn(
|
|
52
|
+
"border-t border-border bg-muted/40 font-medium [&_tr]:border-0",
|
|
53
|
+
className,
|
|
54
|
+
)}
|
|
55
|
+
{...props}
|
|
56
|
+
/>
|
|
57
|
+
));
|
|
58
|
+
TableFooter.displayName = "TableFooter";
|
|
59
|
+
|
|
39
60
|
const TableRow = React.forwardRef<
|
|
40
61
|
HTMLTableRowElement,
|
|
41
62
|
React.HTMLAttributes<HTMLTableRowElement>
|
|
@@ -90,4 +111,12 @@ const TableCell = React.forwardRef<
|
|
|
90
111
|
));
|
|
91
112
|
TableCell.displayName = "TableCell";
|
|
92
113
|
|
|
93
|
-
export {
|
|
114
|
+
export {
|
|
115
|
+
Table,
|
|
116
|
+
TableBody,
|
|
117
|
+
TableCell,
|
|
118
|
+
TableFooter,
|
|
119
|
+
TableHead,
|
|
120
|
+
TableHeader,
|
|
121
|
+
TableRow,
|
|
122
|
+
};
|
|
@@ -15,11 +15,16 @@ export interface UseColumnVisibilityResult {
|
|
|
15
15
|
/**
|
|
16
16
|
* Persiste as colunas ocultas de uma tabela em localStorage (por storageKey).
|
|
17
17
|
* Retorna a lista de ocultas + helpers para ler/salvar.
|
|
18
|
+
*
|
|
19
|
+
* `defaultHidden` é só o ponto de partida: vale enquanto ninguém tiver
|
|
20
|
+
* escolhido as colunas nesta tela. Depois da primeira escolha, a lista guardada
|
|
21
|
+
* é a verdade — inclusive quando ela mostra uma coluna que nasceu escondida.
|
|
18
22
|
*/
|
|
19
23
|
export function useColumnVisibility(
|
|
20
24
|
storageKey?: string,
|
|
25
|
+
defaultHidden: string[] = [],
|
|
21
26
|
): UseColumnVisibilityResult {
|
|
22
|
-
const [hiddenColumns, setHiddenColumns] = useState<string[]>(
|
|
27
|
+
const [hiddenColumns, setHiddenColumns] = useState<string[]>(defaultHidden);
|
|
23
28
|
|
|
24
29
|
useEffect(() => {
|
|
25
30
|
if (!storageKey) {return;}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useCallback, useEffect, useState } from "react";
|
|
4
|
+
|
|
5
|
+
import { safeStorage } from "#core/_utils/storage";
|
|
6
|
+
|
|
7
|
+
const STORAGE_PREFIX = "view:";
|
|
8
|
+
|
|
9
|
+
/** Como a listagem se apresenta: em linhas ou em cartões. */
|
|
10
|
+
export type TableView = "list" | "grid";
|
|
11
|
+
|
|
12
|
+
export interface UseTableViewResult {
|
|
13
|
+
view: TableView;
|
|
14
|
+
setView: (next: TableView) => void;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Guarda em qual formato a pessoa deixou a listagem, por `storageKey`.
|
|
19
|
+
*
|
|
20
|
+
* Mesma ideia das colunas escondidas: é escolha de quem olha, não da tela, e
|
|
21
|
+
* refazê-la a cada visita transformaria o alternador num incômodo. Sem
|
|
22
|
+
* `storageKey` o formato dura só enquanto a tela estiver aberta — igual às
|
|
23
|
+
* colunas, que também só persistem quando a tabela tem chave.
|
|
24
|
+
*/
|
|
25
|
+
export function useTableView(
|
|
26
|
+
storageKey?: string,
|
|
27
|
+
defaultView: TableView = "list",
|
|
28
|
+
): UseTableViewResult {
|
|
29
|
+
const [view, setStoredView] = useState<TableView>(defaultView);
|
|
30
|
+
|
|
31
|
+
useEffect(() => {
|
|
32
|
+
if (!storageKey) {
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
const raw = safeStorage.get(`${STORAGE_PREFIX}${storageKey}`);
|
|
36
|
+
|
|
37
|
+
if (raw === "list" || raw === "grid") {
|
|
38
|
+
setStoredView(raw);
|
|
39
|
+
}
|
|
40
|
+
}, [storageKey]);
|
|
41
|
+
|
|
42
|
+
const setView = useCallback(
|
|
43
|
+
(next: TableView) => {
|
|
44
|
+
setStoredView(next);
|
|
45
|
+
|
|
46
|
+
if (storageKey) {
|
|
47
|
+
safeStorage.set(`${STORAGE_PREFIX}${storageKey}`, next);
|
|
48
|
+
}
|
|
49
|
+
},
|
|
50
|
+
[storageKey],
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
return { view, setView };
|
|
54
|
+
}
|
package/src/i18n/messages/en.ts
CHANGED
|
@@ -165,8 +165,12 @@ export const en: Messages = {
|
|
|
165
165
|
nextPage: "Next page",
|
|
166
166
|
empty: "No records",
|
|
167
167
|
emptyFiltered: "No records match this filter",
|
|
168
|
+
view: "List format",
|
|
169
|
+
viewList: "List",
|
|
170
|
+
viewGrid: "Cards",
|
|
168
171
|
reorder: "Drag to reorder",
|
|
169
172
|
reorderSorted: "Clear the column sorting to drag rows",
|
|
173
|
+
reorderGrouped: "Rows cannot be reordered while the list is grouped",
|
|
170
174
|
},
|
|
171
175
|
filters: {
|
|
172
176
|
button: "Filters",
|
package/src/i18n/messages/pt.ts
CHANGED
|
@@ -167,9 +167,13 @@ export const pt = {
|
|
|
167
167
|
nextPage: "Próxima página",
|
|
168
168
|
empty: "Nenhum registro",
|
|
169
169
|
emptyFiltered: "Nenhum registro para este filtro",
|
|
170
|
+
view: "Formato da listagem",
|
|
171
|
+
viewList: "Lista",
|
|
172
|
+
viewGrid: "Cartões",
|
|
170
173
|
reorder: "Arraste para reordenar",
|
|
171
174
|
reorderSorted:
|
|
172
175
|
"Limpe a ordenação por coluna para arrastar as linhas",
|
|
176
|
+
reorderGrouped: "Não dá para reordenar com a lista dividida em seções",
|
|
173
177
|
},
|
|
174
178
|
filters: {
|
|
175
179
|
button: "Filtros",
|
package/src/index.ts
CHANGED
|
@@ -110,6 +110,8 @@ export {
|
|
|
110
110
|
EXPAND_SHORTCUT_LABEL,
|
|
111
111
|
useShortcut,
|
|
112
112
|
} from "#core/hooks/use-shortcut";
|
|
113
|
+
export type { TableView, UseTableViewResult } from "#core/hooks/use-table-view";
|
|
114
|
+
export { useTableView } from "#core/hooks/use-table-view";
|
|
113
115
|
// RequestOperation é enum (valor, não só tipo): sem ele o projeto não consegue
|
|
114
116
|
// pedir o toast padrão de criado/alterado/removido que o core já traduz.
|
|
115
117
|
export { RequestOperation, useRequest } from "#core/hooks/use-request";
|