rl-core-front 0.15.4 → 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.
@@ -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({
@@ -79,6 +83,14 @@ interface DataTableProps<T extends RowData> {
79
83
  loading?: boolean;
80
84
  /** Ações à esquerda da toolbar (ex.: botão "Novo"). */
81
85
  toolbar?: React.ReactNode;
86
+ /**
87
+ * Ações à direita, antes de "Filtros" e "Colunas".
88
+ *
89
+ * É onde mora o que age sobre **a listagem inteira** — exportar, por
90
+ * exemplo. À esquerda ficam as ações de criar; misturar as duas coisas na
91
+ * mesma ponta faz o botão de exportar parecer mais um "novo".
92
+ */
93
+ toolbarEnd?: React.ReactNode;
82
94
  pageSizeOptions?: number[];
83
95
  /** Paginação no servidor: informe rowCount, pagination e onPaginationChange. */
84
96
  manualPagination?: boolean;
@@ -115,6 +127,18 @@ interface DataTableProps<T extends RowData> {
115
127
  * indistinguível de bug, e manda o usuário procurar defeito onde não há.
116
128
  */
117
129
  emptyMessage?: string;
130
+ /**
131
+ * Esconde o rodapé de contagem, paginação e "por página".
132
+ *
133
+ * Para a listagem que já vem inteira numa resposta — um cadastro de vinte
134
+ * linhas não precisa de página. Sem o rodapé a tabela mostra **todas** as
135
+ * linhas, senão esconder a navegação esconderia registros.
136
+ *
137
+ * Ignorado quando a paginação é do servidor (`manualPagination`): ali a
138
+ * tabela só tem em mãos a página atual, e tirar o rodapé faria as outras
139
+ * sumirem sem aviso.
140
+ */
141
+ hideFooter?: boolean;
118
142
  /**
119
143
  * Arrastar a linha pela alça para reordenar a listagem.
120
144
  *
@@ -128,6 +152,66 @@ interface DataTableProps<T extends RowData> {
128
152
  * gravada, e arrastar escreveria uma sequência que ninguém viu.
129
153
  */
130
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;
131
215
  }
132
216
 
133
217
  /** Extras que uma coluna pode declarar em `meta`. */
@@ -139,6 +223,33 @@ export interface DataTableColumnMeta {
139
223
  * É o caso da coluna de ações: sem isso o `<table>` a esticava.
140
224
  */
141
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;
142
253
  }
143
254
 
144
255
  function columnLabel<T extends RowData>(
@@ -155,6 +266,85 @@ function columnLabel<T extends RowData>(
155
266
  return id;
156
267
  }
157
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
+
158
348
  /**
159
349
  * `w-px` + `whitespace-nowrap` é o jeito de fazer uma coluna de tabela encolher
160
350
  * até o conteúdo: o navegador trata a largura mínima como o conteúdo e o espaço
@@ -172,6 +362,7 @@ export function DataTable<T extends RowData>({
172
362
  storageKey,
173
363
  loading,
174
364
  toolbar,
365
+ toolbarEnd,
175
366
  pageSizeOptions = [10, 20, 50],
176
367
  manualPagination,
177
368
  rowCount,
@@ -184,10 +375,19 @@ export function DataTable<T extends RowData>({
184
375
  filters,
185
376
  hiddenFilterFields,
186
377
  emptyMessage,
378
+ hideFooter = false,
187
379
  onRowReorder,
380
+ renderGrid,
381
+ defaultView = "list",
382
+ groupBy,
383
+ renderGroupHeader,
384
+ groupOrder,
385
+ collapsibleGroups = false,
188
386
  }: DataTableProps<T>): JSX.Element {
189
387
  const { t } = useI18n();
190
388
  const [colsOpen, setColsOpen] = useState(false);
389
+ /** As seções fechadas, por chave. */
390
+ const [collapsedGroups, setCollapsedGroups] = useState<string[]>([]);
191
391
  const [filtersOpen, setFiltersOpen] = useState(false);
192
392
  /** A linha na mão e a linha embaixo do dedo, enquanto o arrasto dura. */
193
393
  const [draggedRow, setDraggedRow] = useState<string | null>(null);
@@ -211,7 +411,22 @@ export function DataTable<T extends RowData>({
211
411
  */
212
412
  const [dragHeight, setDragHeight] = useState(0);
213
413
 
214
- const { hiddenColumns, saveHiddenColumns } = useColumnVisibility(storageKey);
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
+ );
215
430
  const columnVisibility = useMemo<ColumnVisibilityState>(() => {
216
431
  const v: ColumnVisibilityState = {};
217
432
  hiddenColumns.forEach((id) => {
@@ -228,6 +443,16 @@ export function DataTable<T extends RowData>({
228
443
  },
229
444
  );
230
445
 
446
+ /*
447
+ Rodapé escondido é lista inteira na tela: sem a navegação, um `pageSize` de
448
+ 10 esconderia da décima primeira linha em diante sem nada dizer. Não vale
449
+ para a paginação do servidor, onde a tabela só tem a página atual em mãos.
450
+ */
451
+ const semRodape = hideFooter && !manualPagination;
452
+ const paginacaoLocal = semRodape
453
+ ? { pageIndex: 0, pageSize: Math.max(data.length, 1) }
454
+ : internalPagination;
455
+
231
456
  const table = useTable({
232
457
  features: dataTableFeatures,
233
458
  data,
@@ -236,7 +461,7 @@ export function DataTable<T extends RowData>({
236
461
  state: {
237
462
  columnVisibility,
238
463
  sorting: manualSorting ? sorting : internalSorting,
239
- pagination: manualPagination ? pagination : internalPagination,
464
+ pagination: manualPagination ? pagination : paginacaoLocal,
240
465
  },
241
466
  onColumnVisibilityChange: (updater) => {
242
467
  const next =
@@ -257,6 +482,17 @@ export function DataTable<T extends RowData>({
257
482
  ...(manualPagination ? { rowCount: rowCount ?? 0 } : {}),
258
483
  });
259
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
+
260
496
  const hideable: ColumnVisibilityOption[] = table
261
497
  .getAllColumns()
262
498
  .filter((c) => c.getCanHide())
@@ -280,7 +516,30 @@ export function DataTable<T extends RowData>({
280
516
  A coluna da alça continua no lugar para a tabela não mudar de largura — só
281
517
  o botão fica inerte, e o `title` diz o motivo.
282
518
  */
283
- const canReorder = reorderable && (table.state.sorting ?? []).length === 0;
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
+ );
284
543
  const colSpan =
285
544
  (table.getVisibleLeafColumns().length || 1) + (reorderable ? 1 : 0);
286
545
 
@@ -373,6 +632,18 @@ export function DataTable<T extends RowData>({
373
632
  };
374
633
 
375
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
+ );
376
647
  const activeFilters = !filters
377
648
  ? 0
378
649
  : filters.mode === "advanced"
@@ -388,6 +659,82 @@ export function DataTable<T extends RowData>({
388
659
  emptyMessage ??
389
660
  (activeFilters > 0 ? t("table.emptyFiltered") : t("table.empty"));
390
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
+
391
738
  return (
392
739
  <div>
393
740
  {/*
@@ -399,269 +746,307 @@ export function DataTable<T extends RowData>({
399
746
  `min-w-0` é o par obrigatório disso — filho de flex não encolhe abaixo
400
747
  do próprio conteúdo sem ele, e o `flex-wrap` sozinho não bastaria.
401
748
  */}
402
- <div className="mb-3 flex flex-wrap items-center justify-between gap-2">
403
- <div className="flex min-w-0 flex-wrap items-center gap-2">
404
- {toolbar}
405
- </div>
406
- <div className="flex flex-wrap items-center gap-2">
407
- {/* Filtro antes de colunas: filtrar é a ação do dia a dia, escolher
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
408
768
  coluna se faz uma vez e a tela guarda. */}
409
- {filtersEnabled && (
410
- <Button
411
- variant="outline"
412
- size="sm"
413
- onClick={() => setFiltersOpen(true)}
414
- >
415
- <Filter className="h-4 w-4" />
416
- {t("filters.button")}
417
- {activeFilters > 0 && (
418
- <span className="ml-1 rounded-full bg-primary px-1.5 text-xs text-primary-foreground">
419
- {activeFilters}
420
- </span>
421
- )}
422
- </Button>
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>
423
804
  )}
424
- {storageKey && hideable.length > 0 && (
425
- <Button
426
- variant="outline"
427
- size="sm"
428
- onClick={() => setColsOpen(true)}
429
- >
430
- <SlidersHorizontal className="h-4 w-4" />
431
- {t("table.columnsButton")}
432
- </Button>
805
+ {!loading && table.getRowModel().rows.length === 0 && (
806
+ <Card className="p-10 text-center text-muted-foreground">
807
+ {emptyText}
808
+ </Card>
433
809
  )}
434
- </div>
435
- </div>
810
+ {!loading &&
811
+ table.getRowModel().rows.length > 0 &&
812
+ renderGrid(table.getRowModel().rows.map((row) => row.original))}
436
813
 
437
- <Card>
438
- <Table>
439
- <TableHeader>
440
- {table.getHeaderGroups().map((hg) => (
441
- <TableRow key={hg.id}>
442
- {reorderable && (
443
- <TableHead className="w-px">
444
- <span className="sr-only">{t("table.reorder")}</span>
445
- </TableHead>
446
- )}
447
- {hg.headers.map((header) => {
448
- const canSort = header.column.getCanSort();
449
- const sorted = header.column.getIsSorted();
450
- return (
451
- <TableHead
452
- key={header.id}
453
- className={shrinkClass(header.column.columnDef)}
454
- >
455
- {header.isPlaceholder ? null : canSort ? (
456
- <button
457
- className="flex items-center gap-1 hover:text-foreground"
458
- onClick={header.column.getToggleSortingHandler()}
459
- >
460
- {flexRender(
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(
461
859
  header.column.columnDef.header,
462
860
  header.getContext(),
463
- )}
464
- {sorted === "asc" ? (
465
- <ArrowUp className="h-3.5 w-3.5 text-primary" />
466
- ) : sorted === "desc" ? (
467
- <ArrowDown className="h-3.5 w-3.5 text-primary" />
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>
468
919
  ) : (
469
- // Seta dupla apagada na coluna livre e seta única
470
- // em destaque na que ordena: sem a cor, achar a
471
- // coluna ativa exige comparar o desenho de todas.
472
- <ArrowUpDown className="h-3.5 w-3.5 opacity-40" />
920
+ renderGroupHeader(section.header)
473
921
  )}
474
- </button>
475
- ) : (
476
- flexRender(
477
- header.column.columnDef.header,
478
- header.getContext(),
479
- )
480
- )}
481
- </TableHead>
482
- );
483
- })}
484
- </TableRow>
485
- ))}
486
- </TableHeader>
487
- <TableBody>
488
- {loading && (
489
- <TableRow>
490
- <TableCell colSpan={colSpan} className="h-24 text-center">
491
- <Spinner className="mx-auto" />
492
- </TableCell>
493
- </TableRow>
494
- )}
495
- {!loading && table.getRowModel().rows.length === 0 && (
496
- <TableRow>
497
- <TableCell
498
- colSpan={colSpan}
499
- className="h-24 text-center text-muted-foreground"
500
- >
501
- {emptyText}
502
- </TableCell>
503
- </TableRow>
504
- )}
505
- {!loading &&
506
- table.getRowModel().rows.map((row, index) => {
507
- const dragging = draggedRow === row.id;
508
- const shift = dragging ? 0 : rowShift(index);
509
-
510
- return (
511
- <TableRow
512
- key={row.id}
513
- data-row-id={row.id}
514
- className={cn(
515
- // A linha viaja com o dedo, destacada do resto da tabela —
516
- // é o que diz que o arrasto pegou, e qual linha pegou.
517
- dragging &&
518
- "relative z-20 bg-card shadow-xl ring-1 ring-primary/50",
519
- draggedRow !== null && "select-none",
520
- /*
521
- A transição existe só enquanto o arrasto dura, e só nas
522
- linhas paradas: na que segue o dedo ela viraria atraso,
523
- e depois de soltar ela animaria a volta ao zero por
524
- cima da lista já reordenada — dois movimentos ao mesmo
525
- tempo, que é o tremor que se vê ao largar a linha.
526
- */
527
- draggedRow !== null &&
528
- !dragging &&
529
- "transition-transform duration-200",
922
+ </TableCell>
923
+ </TableRow>
530
924
  )}
531
- style={
532
- dragging
533
- ? {
534
- // O `scale` é o "levantou da mesa": a linha na mão
535
- // fica maior que as paradas, como o card que sai
536
- // da coluna no quadro.
537
- transform: `translateY(${dragOffset}px) scale(1.01)`,
538
- }
539
- : shift !== 0
540
- ? { transform: `translateY(${shift}px)` }
541
- : undefined
542
- }
543
- >
544
- {reorderable && (
545
- <TableCell className="w-px pr-0">
546
- <button
547
- type="button"
548
- aria-label={t("table.reorder")}
549
- title={
550
- canReorder
551
- ? t("table.reorder")
552
- : t("table.reorderSorted")
553
- }
554
- disabled={!canReorder}
555
- // `touch-none` é o que impede a página de rolar embaixo
556
- // 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}
557
935
  className={cn(
558
- "flex touch-none items-center text-muted-foreground",
559
- canReorder
560
- ? "cursor-grab active:cursor-grabbing hover:text-foreground"
561
- : "cursor-not-allowed opacity-30",
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",
562
951
  )}
563
- onPointerDown={
564
- canReorder ? startRowDrag(row.id) : undefined
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
565
963
  }
566
- onPointerMove={canReorder ? moveRowDrag : undefined}
567
- onPointerUp={canReorder ? endRowDrag : undefined}
568
- onPointerCancel={canReorder ? endRowDrag : undefined}
569
964
  >
570
- <GripVertical className="h-4 w-4" />
571
- </button>
572
- </TableCell>
573
- )}
574
- {row.getVisibleCells().map((cell) => (
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) => (
575
1029
  <TableCell
576
- key={cell.id}
577
- className={shrinkClass(cell.column.columnDef)}
1030
+ key={header.id}
1031
+ className={shrinkClass(header.column.columnDef)}
578
1032
  >
579
- {flexRender(
580
- cell.column.columnDef.cell,
581
- cell.getContext(),
582
- )}
1033
+ {header.isPlaceholder
1034
+ ? null
1035
+ : flexRender(
1036
+ header.column.columnDef.footer,
1037
+ header.getContext(),
1038
+ )}
583
1039
  </TableCell>
584
1040
  ))}
585
1041
  </TableRow>
586
- );
587
- })}
588
- </TableBody>
589
- </Table>
590
-
591
- {/*
592
- Três blocos, e a ordem tem motivo: quantos são à esquerda, para onde
593
- ir no meio, e o tamanho da página à direita — o controle que se mexe
594
- uma vez fica na ponta, e a navegação, que se usa o tempo todo, no
595
- centro. Em tela estreita eles empilham em vez de espremer.
596
- */}
597
- <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">
598
- <span className="sm:flex-1">
599
- {total} {t("table.results")}
600
- </span>
601
-
602
- <div className="flex items-center gap-1">
603
- <Button
604
- variant="ghost"
605
- size="icon"
606
- aria-label={t("table.previousPage")}
607
- disabled={!table.getCanPreviousPage()}
608
- onClick={() => table.previousPage()}
609
- >
610
- <ChevronLeft className="h-4 w-4" />
611
- </Button>
612
-
613
- {pageRange(pageIndex, pageCount).map((item, posicao) =>
614
- item === "gap" ? (
615
- // O buraco não é botão: `key` pela posição porque duas elipses
616
- // são indistinguíveis entre si.
617
- <span
618
- key={`gap-${posicao}`}
619
- className="px-1 text-muted-foreground"
620
- >
621
- …
622
- </span>
623
- ) : (
624
- <Button
625
- key={item}
626
- variant={item === pageIndex + 1 ? "default" : "ghost"}
627
- size="icon"
628
- // Quadrado de 40px como base, mas cresce com o número: a
629
- // página 128 não pode vazar do botão.
630
- className="w-auto min-w-10 px-2 tabular-nums"
631
- aria-current={item === pageIndex + 1 ? "page" : undefined}
632
- onClick={() => table.setPageIndex(item - 1)}
633
- >
634
- {item}
635
- </Button>
636
- ),
1042
+ ))}
1043
+ </TableFooter>
637
1044
  )}
1045
+ </Table>
638
1046
 
639
- <Button
640
- variant="ghost"
641
- size="icon"
642
- aria-label={t("table.nextPage")}
643
- disabled={!table.getCanNextPage()}
644
- onClick={() => table.nextPage()}
645
- >
646
- <ChevronRight className="h-4 w-4" />
647
- </Button>
648
- </div>
649
-
650
- <div className="sm:flex-1 sm:text-right">
651
- <select
652
- value={pageSize}
653
- onChange={(e) => table.setPageSize(Number(e.target.value))}
654
- className="h-9 rounded-md border border-input bg-transparent px-2 text-sm"
655
- >
656
- {pageSizeOptions.map((n) => (
657
- <option key={n} value={n}>
658
- {n} {t("table.perPage")}
659
- </option>
660
- ))}
661
- </select>
662
- </div>
663
- </div>
664
- </Card>
1047
+ {!semRodape && rodape}
1048
+ </Card>
1049
+ )}
665
1050
 
666
1051
  {storageKey && (
667
1052
  <ColumnVisibilityModal