rl-core-front 0.19.5 → 0.19.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rl-core-front",
3
- "version": "0.19.5",
3
+ "version": "0.19.6",
4
4
  "description": "Telas e componentes Next.js do core: login com 2FA, usuários, RBAC, auditoria, logs e listagens com filtro dinâmico",
5
5
  "author": "Rodrigo Liberti",
6
6
  "license": "MIT",
@@ -280,6 +280,25 @@ interface DataTableProps<T extends RowData> {
280
280
  * (a receber / recebidas) declara esta lista.
281
281
  */
282
282
  groupOrder?: string[];
283
+ /**
284
+ * A linha que um link aponta — pelo id de `getRowId`.
285
+ *
286
+ * A tabela abre a seção em que ela está, rola até ela e a destaca por alguns
287
+ * segundos: é o que faz o aviso "luz venceu" cair na linha da luz, e não no
288
+ * topo de um mês de trinta lançamentos. Acontece quando o id troca e quando
289
+ * a linha chega (o dado costuma vir depois do link); fora disso a linha é
290
+ * uma linha como as outras — o destaque é um sinal, não um estado.
291
+ *
292
+ * Exige `getRowId`. Id que não está em `data` não faz nada.
293
+ */
294
+ highlightRowId?: string | null;
295
+ /**
296
+ * A seção que um link aponta — pela chave de `groupBy`.
297
+ *
298
+ * O mesmo gesto, na faixa: para o aviso que fala da fatura inteira, e não de
299
+ * uma parcela dela.
300
+ */
301
+ highlightGroupKey?: string | null;
283
302
  }
284
303
 
285
304
  /** Uma seção da listagem, como o cabeçalho de grupo a recebe. */
@@ -458,6 +477,20 @@ function shrinkClass(columnDef: { meta?: unknown }): string | undefined {
458
477
  */
459
478
  const CELL_MAX_WIDTH = "24rem";
460
479
 
480
+ /**
481
+ * Quanto tempo a linha apontada fica destacada.
482
+ *
483
+ * O bastante para o olho chegar depois da rolagem e curto o bastante para não
484
+ * parecer seleção: passado isso, a cor esmaece e a linha volta ao normal.
485
+ */
486
+ const HIGHLIGHT_MS = 3000;
487
+
488
+ /** O que está destacado agora — a linha, a faixa, ou nada. */
489
+ interface Spotlight {
490
+ rowId: string | null;
491
+ groupKey: string | null;
492
+ }
493
+
461
494
  /**
462
495
  * Como a célula se comporta quando o conteúdo não cabe: corta com reticências.
463
496
  *
@@ -571,6 +604,8 @@ export function DataTable<T extends RowData>({
571
604
  groupOrder,
572
605
  collapsibleGroups = false,
573
606
  defaultCollapsedGroups,
607
+ highlightRowId = null,
608
+ highlightGroupKey = null,
574
609
  }: DataTableProps<T>): JSX.Element {
575
610
  const { t } = useI18n();
576
611
  const [colsOpen, setColsOpen] = useState(false);
@@ -578,6 +613,93 @@ export function DataTable<T extends RowData>({
578
613
  const [collapsedGroups, setCollapsedGroups] = useState<string[]>(
579
614
  () => defaultCollapsedGroups ?? [],
580
615
  );
616
+ /*
617
+ O que está apontado e se ainda está aceso.
618
+
619
+ Dois estados porque a transição de cor fica ligada na linha apontada mesmo
620
+ depois de a cor sair: se ela morasse só enquanto acesa, sumiria junto com
621
+ a cor e o esmaecer viraria um corte. E só nessa linha, e não em todas —
622
+ com a transição na tabela inteira, o hover de cada linha demoraria para
623
+ acender.
624
+ */
625
+ const [spotlight, setSpotlight] = useState<Spotlight | null>(null);
626
+ const [lit, setLit] = useState(false);
627
+ /** O pedido já atendido — para o mesmo link não acender a linha a cada render. */
628
+ const [seenHighlight, setSeenHighlight] = useState<string | null>(null);
629
+ const bodyRef = useRef<HTMLTableSectionElement>(null);
630
+
631
+ /*
632
+ A linha apontada, se ela está em `data`; e a seção dela, para abrir.
633
+
634
+ Procurada em `data` e não no modelo da tabela: fechada, a seção tira as
635
+ linhas do modelo, e a linha que se quer abrir é justamente a que está
636
+ escondida.
637
+ */
638
+ const highlightedRow =
639
+ highlightRowId !== null && getRowId
640
+ ? data.find((row) => getRowId(row) === highlightRowId)
641
+ : undefined;
642
+ const highlightedGroup =
643
+ highlightGroupKey ??
644
+ (highlightedRow !== undefined && groupBy ? groupBy(highlightedRow) : null);
645
+ const highlightTarget =
646
+ highlightedRow !== undefined ||
647
+ (highlightGroupKey !== null && Boolean(groupBy) &&
648
+ data.some((row) => groupBy?.(row) === highlightGroupKey));
649
+ const highlightKey = highlightTarget
650
+ ? `${highlightRowId ?? ""}|${highlightGroupKey ?? ""}`
651
+ : null;
652
+
653
+ /*
654
+ Reage ao link no próprio render, e não num efeito: é o padrão do React
655
+ para "estado que depende da prop anterior", e poupa o quadro em que a
656
+ seção ainda estaria fechada. Quando o alvo some da lista (a tela trocou de
657
+ mês), o pedido é esquecido — e a linha acende de novo se voltar.
658
+ */
659
+ if (highlightKey !== seenHighlight) {
660
+ setSeenHighlight(highlightKey);
661
+
662
+ if (highlightKey !== null) {
663
+ setSpotlight({ rowId: highlightRowId, groupKey: highlightGroupKey });
664
+ setLit(true);
665
+
666
+ if (highlightedGroup !== null) {
667
+ setCollapsedGroups((atual) =>
668
+ atual.filter((fechada) => fechada !== highlightedGroup),
669
+ );
670
+ }
671
+ }
672
+ }
673
+
674
+ useEffect(() => {
675
+ if (!lit) {
676
+ return;
677
+ }
678
+
679
+ const timer = window.setTimeout(() => setLit(false), HIGHLIGHT_MS);
680
+
681
+ return () => window.clearTimeout(timer);
682
+ // `spotlight` entra para o relógio recomeçar quando o alvo troca com a
683
+ // linha anterior ainda acesa.
684
+ }, [lit, spotlight]);
685
+
686
+ // Rola depois de a seção abrir: a linha só existe no DOM depois do render
687
+ // que a abriu, e um efeito roda justamente depois dele.
688
+ useEffect(() => {
689
+ if (spotlight === null) {
690
+ return;
691
+ }
692
+
693
+ const selector =
694
+ spotlight.rowId !== null
695
+ ? `[data-row-id="${CSS.escape(spotlight.rowId)}"]`
696
+ : spotlight.groupKey !== null
697
+ ? `[data-group-key="${CSS.escape(spotlight.groupKey)}"]`
698
+ : null;
699
+ const alvo = selector ? bodyRef.current?.querySelector(selector) : null;
700
+
701
+ alvo?.scrollIntoView({ block: "center", behavior: "smooth" });
702
+ }, [spotlight]);
581
703
  const [filtersOpen, setFiltersOpen] = useState(false);
582
704
  /** A linha na mão e a linha embaixo do dedo, enquanto o arrasto dura. */
583
705
  const [draggedRow, setDraggedRow] = useState<string | null>(null);
@@ -1102,7 +1224,7 @@ export function DataTable<T extends RowData>({
1102
1224
  </TableRow>
1103
1225
  ))}
1104
1226
  </TableHeader>
1105
- <TableBody>
1227
+ <TableBody ref={bodyRef}>
1106
1228
  {loading && (
1107
1229
  <TableRow>
1108
1230
  <TableCell colSpan={colSpan} className="h-24 text-center">
@@ -1124,13 +1246,21 @@ export function DataTable<T extends RowData>({
1124
1246
  sections.map((section) => (
1125
1247
  <Fragment key={section.key}>
1126
1248
  {section.header && renderGroupHeader && (
1127
- <TableRow className="hover:bg-transparent">
1249
+ <TableRow
1250
+ data-group-key={section.key}
1251
+ className="hover:bg-transparent"
1252
+ >
1128
1253
  {/* Uma célula atravessando a tabela: é o que faz a
1129
1254
  faixa parecer cabeçalho, e não registro sem
1130
1255
  dados. */}
1131
1256
  <TableCell
1132
1257
  colSpan={colSpan}
1133
- className="bg-muted/40 py-2"
1258
+ className={cn(
1259
+ "bg-muted/40 py-2",
1260
+ spotlight?.groupKey === section.key &&
1261
+ "transition-colors duration-700",
1262
+ lit && spotlight?.groupKey === section.key && "bg-primary/15",
1263
+ )}
1134
1264
  >
1135
1265
  {collapsibleGroups ? (
1136
1266
  <button
@@ -1174,6 +1304,13 @@ export function DataTable<T extends RowData>({
1174
1304
  dragging &&
1175
1305
  "relative z-20 bg-card shadow-xl ring-1 ring-primary/50",
1176
1306
  draggedRow !== null && "select-none",
1307
+ // A linha apontada acende e esmaece: a cor some
1308
+ // sozinha quando o destaque acaba, e a transição
1309
+ // é o que faz isso parecer "olhe aqui", e não
1310
+ // uma seleção que ficou.
1311
+ spotlight?.rowId === row.id &&
1312
+ "transition-colors duration-700",
1313
+ lit && spotlight?.rowId === row.id && "bg-primary/15",
1177
1314
  /*
1178
1315
  A transição existe só enquanto o arrasto dura, e só nas
1179
1316
  linhas paradas: na que segue o dedo ela viraria atraso,
@@ -1,16 +1,11 @@
1
1
  "use client";
2
2
 
3
- import {
4
- useCallback,
5
- useEffect,
6
- useLayoutEffect,
7
- useRef,
8
- useSyncExternalStore,
9
- } from "react";
3
+ import { useCallback, useEffect, useLayoutEffect, useRef } from "react";
10
4
 
11
5
  import { safeStorage } from "#core/_utils/storage";
12
6
  import { useIsHydrated } from "#core/hooks/use-is-hydrated";
13
7
  import { useStoredValue } from "#core/hooks/use-stored-value";
8
+ import { readUrlParam, useUrlParam } from "#core/hooks/use-url-param";
14
9
 
15
10
  const STORAGE_PREFIX = "tab:";
16
11
  const DEFAULT_PARAM = "tab";
@@ -33,35 +28,6 @@ export interface UseTabStateResult<T extends string> {
33
28
  setTab: (next: string) => void;
34
29
  }
35
30
 
36
- const readFromUrl = (param: string): string | null =>
37
- new URLSearchParams(window.location.search).get(param);
38
-
39
- /** Quem lê a querystring, para saber que ela mudou por aqui. */
40
- const urlListeners = new Set<() => void>();
41
-
42
- const subscribeUrl = (onChange: () => void): (() => void) => {
43
- urlListeners.add(onChange);
44
- // O "voltar" do navegador também troca a aba, e esse não passa por aqui.
45
- window.addEventListener("popstate", onChange);
46
- return () => {
47
- urlListeners.delete(onChange);
48
- window.removeEventListener("popstate", onChange);
49
- };
50
- };
51
-
52
- /** Troca só o próprio parâmetro, preservando o resto — o `?filter=` da listagem mora ao lado. */
53
- const writeToUrl = (param: string, value: string): void => {
54
- const params = new URLSearchParams(window.location.search);
55
- params.set(param, value);
56
- window.history.replaceState(null, "", `${window.location.pathname}?${params.toString()}`);
57
-
58
- // `replaceState` não dispara evento nenhum: sem este aviso, a tela ficaria
59
- // na aba antiga até o próximo render por outro motivo.
60
- for (const listener of urlListeners) {
61
- listener();
62
- }
63
- };
64
-
65
31
  /**
66
32
  * Em qual aba a pessoa deixou a tela, por `storageKey`.
67
33
  *
@@ -93,11 +59,7 @@ export function useTabState<T extends string>(
93
59
  // dentro de um efeito: no primeiro render do Next não há `window`, e a
94
60
  // leitura do cliente entra assim que ele hidrata, sem um quadro na aba
95
61
  // errada.
96
- const urlTab = useSyncExternalStore(
97
- subscribeUrl,
98
- () => readFromUrl(param),
99
- () => null,
100
- );
62
+ const [urlTab, writeUrlTab] = useUrlParam(param);
101
63
  const storedTab = useStoredValue(`${STORAGE_PREFIX}${storageKey}`);
102
64
 
103
65
  const isTab = (value: string | null): value is T =>
@@ -127,10 +89,10 @@ export function useTabState<T extends string>(
127
89
  if (!hydrated) {
128
90
  return;
129
91
  }
130
- if (tab !== undefined && readFromUrl(param) !== tab) {
131
- writeToUrl(param, tab);
92
+ if (tab !== undefined && readUrlParam(param) !== tab) {
93
+ writeUrlTab(tab);
132
94
  }
133
- }, [hydrated, tab, param]);
95
+ }, [hydrated, tab, param, writeUrlTab]);
134
96
 
135
97
  const setTab = useCallback(
136
98
  (next: string): void => {
@@ -139,10 +101,10 @@ export function useTabState<T extends string>(
139
101
  }
140
102
 
141
103
  // Escrever nos dois já avisa quem lê: a aba vem da URL e do storage.
142
- writeToUrl(param, next);
104
+ writeUrlTab(next);
143
105
  safeStorage.set(`${STORAGE_PREFIX}${storageKey}`, next);
144
106
  },
145
- [storageKey, param],
107
+ [storageKey, writeUrlTab],
146
108
  );
147
109
 
148
110
  return { tab, setTab };
@@ -0,0 +1,80 @@
1
+ "use client";
2
+
3
+ import { useCallback, useSyncExternalStore } from "react";
4
+
5
+ /** Quem lê a querystring, para saber que ela mudou por aqui. */
6
+ const listeners = new Set<() => void>();
7
+
8
+ const subscribe = (onChange: () => void): (() => void) => {
9
+ listeners.add(onChange);
10
+ // O "voltar" do navegador também troca o valor, e esse não passa por aqui.
11
+ window.addEventListener("popstate", onChange);
12
+ return () => {
13
+ listeners.delete(onChange);
14
+ window.removeEventListener("popstate", onChange);
15
+ };
16
+ };
17
+
18
+ /** O valor de um parâmetro da URL, ou `null` quando ele não está lá. */
19
+ export const readUrlParam = (name: string): string | null =>
20
+ new URLSearchParams(window.location.search).get(name);
21
+
22
+ /**
23
+ * Troca só o próprio parâmetro, preservando o resto — o `?filter=` da listagem
24
+ * mora ao lado. `null` o remove.
25
+ *
26
+ * `replaceState`, e não `router.replace`: trocar um parâmetro não é navegação,
27
+ * e empilhar histórico faria o "voltar" percorrer valor por valor antes de sair
28
+ * da tela.
29
+ */
30
+ export const writeUrlParam = (name: string, value: string | null): void => {
31
+ const params = new URLSearchParams(window.location.search);
32
+
33
+ if (value === null) {
34
+ params.delete(name);
35
+ } else {
36
+ params.set(name, value);
37
+ }
38
+
39
+ const query = params.toString();
40
+ window.history.replaceState(
41
+ null,
42
+ "",
43
+ query ? `${window.location.pathname}?${query}` : window.location.pathname,
44
+ );
45
+
46
+ // `replaceState` não dispara evento nenhum: sem este aviso, quem lê o
47
+ // parâmetro ficaria com o valor antigo até o próximo render por outro motivo.
48
+ for (const listener of listeners) {
49
+ listener();
50
+ }
51
+ };
52
+
53
+ /**
54
+ * Um parâmetro da querystring como estado: `[valor, trocar]`.
55
+ *
56
+ * É o que faz o F5 voltar onde estava e transforma o estado em link
57
+ * compartilhável — o mês em exibição, a linha que um aviso aponta. Lido no
58
+ * próprio render, e não copiado para um estado num efeito: no primeiro render
59
+ * do Next não há `window`, e a leitura do cliente entra assim que ele hidrata,
60
+ * sem um quadro no valor errado.
61
+ *
62
+ * O servidor sempre enxerga `null`: quem tem padrão aplica na leitura
63
+ * (`value ?? padrao`), e o padrão vale nos dois lados.
64
+ */
65
+ export function useUrlParam(
66
+ name: string,
67
+ ): [value: string | null, set: (value: string | null) => void] {
68
+ const value = useSyncExternalStore(
69
+ subscribe,
70
+ () => readUrlParam(name),
71
+ () => null,
72
+ );
73
+
74
+ const set = useCallback(
75
+ (next: string | null): void => writeUrlParam(name, next),
76
+ [name],
77
+ );
78
+
79
+ return [value, set];
80
+ }
package/src/index.ts CHANGED
@@ -131,6 +131,7 @@ export type { UseTabStateOptions, UseTabStateResult } from "#core/hooks/use-tab-
131
131
  export { useTabState } from "#core/hooks/use-tab-state";
132
132
  export type { TableView, UseTableViewResult } from "#core/hooks/use-table-view";
133
133
  export { useTableView } from "#core/hooks/use-table-view";
134
+ export { useUrlParam } from "#core/hooks/use-url-param";
134
135
  // RequestOperation é enum (valor, não só tipo): sem ele o projeto não consegue
135
136
  // pedir o toast padrão de criado/alterado/removido que o core já traduz.
136
137
  export { RequestOperation, useRequest } from "#core/hooks/use-request";