rl-core-front 0.11.2 → 0.11.4

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rl-core-front",
3
- "version": "0.11.2",
3
+ "version": "0.11.4",
4
4
  "description": "Telas e componentes Next.js do core: login com 2FA, usu\u00e1rios, RBAC, auditoria, logs e listagens com filtro din\u00e2mico",
5
5
  "author": "Rodrigo Liberti",
6
6
  "license": "MIT",
@@ -0,0 +1,36 @@
1
+ /** Um item da barra de páginas: o número da página, ou o buraco entre elas. */
2
+ export type PageItem = number | "gap";
3
+
4
+ /** Quantas páginas cabem sem precisar esconder nenhuma. */
5
+ const SEM_BURACO = 7;
6
+
7
+ /**
8
+ * A régua de páginas do rodapé: `1 2 3 4 … 18`.
9
+ *
10
+ * Primeira e última sempre aparecem — são os dois saltos que mais se usam —, e
11
+ * no meio fica a vizinhança de onde a pessoa está. Com poucas páginas não há o
12
+ * que esconder e todas saem; daí em diante a lista tem largura fixa, para o
13
+ * rodapé não mudar de tamanho a cada clique.
14
+ *
15
+ * `pageIndex` é o índice do TanStack (base zero); o que sai já é número de
16
+ * página (base um), que é o que se lê na tela.
17
+ */
18
+ export const pageRange = (pageIndex: number, pageCount: number): PageItem[] => {
19
+ const total = Math.max(1, pageCount);
20
+ const atual = Math.min(Math.max(1, pageIndex + 1), total);
21
+
22
+ if (total <= SEM_BURACO) {
23
+ return Array.from({ length: total }, (_, i) => i + 1);
24
+ }
25
+
26
+ // Nas pontas, a janela se estende para o lado que tem páginas: perto do
27
+ // começo, mostrar "… 2 3 4 …" desperdiçaria o espaço da esquerda.
28
+ if (atual <= 3) {
29
+ return [1, 2, 3, 4, "gap", total];
30
+ }
31
+ if (atual >= total - 2) {
32
+ return [1, "gap", total - 3, total - 2, total - 1, total];
33
+ }
34
+
35
+ return [1, "gap", atual - 1, atual, atual + 1, "gap", total];
36
+ };
@@ -33,6 +33,7 @@ import { useMemo, useState } from "react";
33
33
 
34
34
  import { countConditions } from "#core/_utils/advanced-filter";
35
35
  import { activeFilterCount } from "#core/_utils/filter";
36
+ import { pageRange } from "#core/_utils/page-range";
36
37
  import { Button } from "#core/components/ui/button";
37
38
  import { Card } from "#core/components/ui/card";
38
39
  import {
@@ -251,9 +252,20 @@ export function DataTable<T extends RowData>({
251
252
 
252
253
  return (
253
254
  <div>
254
- <div className="mb-3 flex items-center justify-between gap-2">
255
- <div className="flex items-center gap-2">{toolbar}</div>
256
- <div className="flex items-center gap-2">
255
+ {/*
256
+ A barra quebra em vez de empurrar a página.
257
+
258
+ Sem `flex-wrap`, uma busca de 288px ao lado de "Novo" + "Filtros" +
259
+ "Colunas" passava da largura do celular, e quem rolava para o lado era o
260
+ documento inteiro: a tabela saía da tela com o cabeçalho junto. O
261
+ `min-w-0` é o par obrigatório disso — filho de flex não encolhe abaixo
262
+ do próprio conteúdo sem ele, e o `flex-wrap` sozinho não bastaria.
263
+ */}
264
+ <div className="mb-3 flex flex-wrap items-center justify-between gap-2">
265
+ <div className="flex min-w-0 flex-wrap items-center gap-2">
266
+ {toolbar}
267
+ </div>
268
+ <div className="flex flex-wrap items-center gap-2">
257
269
  {/* Filtro antes de colunas: filtrar é a ação do dia a dia, escolher
258
270
  coluna se faz uma vez e a tela guarda. */}
259
271
  {filtersEnabled && (
@@ -366,44 +378,78 @@ export function DataTable<T extends RowData>({
366
378
  </TableBody>
367
379
  </Table>
368
380
 
369
- <div className="flex flex-col gap-3 border-t border-border px-4 py-3 text-sm text-muted-foreground sm:flex-row sm:items-center sm:justify-between">
370
- <div className="flex items-center gap-3">
371
- <select
372
- value={pageSize}
373
- onChange={(e) => table.setPageSize(Number(e.target.value))}
374
- className="h-9 rounded-md border border-input bg-transparent px-2 text-sm"
375
- >
376
- {pageSizeOptions.map((n) => (
377
- <option key={n} value={n}>
378
- {n} {t("table.perPage")}
379
- </option>
380
- ))}
381
- </select>
382
- <span>
383
- {total} {t("table.results")}
384
- </span>
385
- </div>
386
- <div className="flex items-center gap-2">
381
+ {/*
382
+ Três blocos, e a ordem tem motivo: quantos são à esquerda, para onde
383
+ ir no meio, e o tamanho da página à direita — o controle que se mexe
384
+ uma vez fica na ponta, e a navegação, que se usa o tempo todo, no
385
+ centro. Em tela estreita eles empilham em vez de espremer.
386
+ */}
387
+ <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">
388
+ <span className="sm:flex-1">
389
+ {total} {t("table.results")}
390
+ </span>
391
+
392
+ <div className="flex items-center gap-1">
387
393
  <Button
388
- variant="outline"
394
+ variant="ghost"
389
395
  size="icon"
396
+ aria-label={t("table.previousPage")}
390
397
  disabled={!table.getCanPreviousPage()}
391
398
  onClick={() => table.previousPage()}
392
399
  >
393
400
  <ChevronLeft className="h-4 w-4" />
394
401
  </Button>
395
- <span>
396
- {pageIndex + 1} / {Math.max(1, pageCount)}
397
- </span>
402
+
403
+ {pageRange(pageIndex, pageCount).map((item, posicao) =>
404
+ item === "gap" ? (
405
+ // O buraco não é botão: `key` pela posição porque duas elipses
406
+ // são indistinguíveis entre si.
407
+ <span
408
+ key={`gap-${posicao}`}
409
+ className="px-1 text-muted-foreground"
410
+ >
411
+
412
+ </span>
413
+ ) : (
414
+ <Button
415
+ key={item}
416
+ variant={item === pageIndex + 1 ? "default" : "ghost"}
417
+ size="icon"
418
+ // Quadrado de 40px como base, mas cresce com o número: a
419
+ // página 128 não pode vazar do botão.
420
+ className="w-auto min-w-10 px-2 tabular-nums"
421
+ aria-current={item === pageIndex + 1 ? "page" : undefined}
422
+ onClick={() => table.setPageIndex(item - 1)}
423
+ >
424
+ {item}
425
+ </Button>
426
+ ),
427
+ )}
428
+
398
429
  <Button
399
- variant="outline"
430
+ variant="ghost"
400
431
  size="icon"
432
+ aria-label={t("table.nextPage")}
401
433
  disabled={!table.getCanNextPage()}
402
434
  onClick={() => table.nextPage()}
403
435
  >
404
436
  <ChevronRight className="h-4 w-4" />
405
437
  </Button>
406
438
  </div>
439
+
440
+ <div className="sm:flex-1 sm:text-right">
441
+ <select
442
+ value={pageSize}
443
+ onChange={(e) => table.setPageSize(Number(e.target.value))}
444
+ className="h-9 rounded-md border border-input bg-transparent px-2 text-sm"
445
+ >
446
+ {pageSizeOptions.map((n) => (
447
+ <option key={n} value={n}>
448
+ {n} {t("table.perPage")}
449
+ </option>
450
+ ))}
451
+ </select>
452
+ </div>
407
453
  </div>
408
454
  </Card>
409
455
 
@@ -15,7 +15,16 @@ const TabsList = React.forwardRef<
15
15
  >(({ className, ...props }, ref) => (
16
16
  <TabsPrimitive.List
17
17
  ref={ref}
18
- className={cn("inline-flex", TRACK_CLASS, className)}
18
+ /*
19
+ * Seis abas não cabem num celular, e a fila inteira empurrava a página para
20
+ * o lado. Agora quem rola é a própria fila: `max-w-full` para ela parar na
21
+ * largura da tela, e `overflow-x-auto` para o resto continuar alcançável.
22
+ */
23
+ className={cn(
24
+ "inline-flex max-w-full overflow-x-auto",
25
+ TRACK_CLASS,
26
+ className,
27
+ )}
19
28
  {...props}
20
29
  />
21
30
  ));
@@ -20,7 +20,9 @@ export const TRACK_CLASS =
20
20
  * opção selecionada sumir no tema escuro.
21
21
  */
22
22
  export const TRACK_ITEM_CLASS = [
23
- "inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1.5",
23
+ // `shrink-0`: numa trilha que rola, a opção mantém o tamanho em vez de ser
24
+ // espremida até o texto virar duas letras.
25
+ "inline-flex shrink-0 items-center justify-center whitespace-nowrap rounded-md px-3 py-1.5",
24
26
  "text-sm font-medium ring-offset-background transition-all",
25
27
  "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1",
26
28
  "disabled:pointer-events-none disabled:opacity-50",
@@ -118,6 +118,8 @@ export const en: Messages = {
118
118
  atLeastOne: "At least one column must remain visible",
119
119
  results: "results",
120
120
  perPage: "per page",
121
+ previousPage: "Previous page",
122
+ nextPage: "Next page",
121
123
  empty: "No records",
122
124
  emptyFiltered: "No records match this filter",
123
125
  },
@@ -120,6 +120,8 @@ export const pt = {
120
120
  atLeastOne: "Pelo menos uma coluna deve permanecer visível",
121
121
  results: "resultados",
122
122
  perPage: "por página",
123
+ previousPage: "Página anterior",
124
+ nextPage: "Próxima página",
123
125
  empty: "Nenhum registro",
124
126
  emptyFiltered: "Nenhum registro para este filtro",
125
127
  },