rl-core-front 0.16.0 → 0.16.1

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.16.0",
3
+ "version": "0.16.1",
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",
@@ -11,6 +11,8 @@ import {
11
11
  PaginationState,
12
12
  RowData,
13
13
  rowPaginationFeature,
14
+ rowSelectionFeature,
15
+ RowSelectionState,
14
16
  rowSortingFeature,
15
17
  sortFn_alphanumeric,
16
18
  sortFn_basic,
@@ -30,13 +32,14 @@ import {
30
32
  SlidersHorizontal,
31
33
  } from "lucide-react";
32
34
  import type { JSX, PointerEvent as ReactPointerEvent } from "react";
33
- import { Fragment, useMemo, useRef, useState } from "react";
35
+ import { Fragment, useEffect, useMemo, useRef, useState } from "react";
34
36
 
35
37
  import { countConditions } from "#core/_utils/advanced-filter";
36
38
  import { activeFilterCount } from "#core/_utils/filter";
37
39
  import { pageRange } from "#core/_utils/page-range";
38
40
  import { Button } from "#core/components/ui/button";
39
41
  import { Card } from "#core/components/ui/card";
42
+ import { Checkbox } from "#core/components/ui/checkbox";
40
43
  import {
41
44
  ColumnVisibilityModal,
42
45
  ColumnVisibilityOption,
@@ -62,6 +65,7 @@ import { cn } from "#core/lib/utils";
62
65
 
63
66
  export const dataTableFeatures = tableFeatures({
64
67
  columnVisibilityFeature,
68
+ rowSelectionFeature,
65
69
  rowSortingFeature,
66
70
  rowPaginationFeature,
67
71
  sortedRowModel: createSortedRowModel(),
@@ -75,6 +79,48 @@ export const dataTableFeatures = tableFeatures({
75
79
 
76
80
  export type DataTableFeatures = typeof dataTableFeatures;
77
81
 
82
+ /**
83
+ * A coluna das caixas de seleção.
84
+ *
85
+ * Fora do componente porque ela não depende de nada dele além dos dois rótulos
86
+ * — e defini-la dentro faria uma coluna nova a cada render, que é o suficiente
87
+ * para o TanStack remontar a tabela e perder a seleção no meio do clique.
88
+ *
89
+ * A caixa do cabeçalho marca **a página**, e não a base: com paginação no
90
+ * servidor, "todas" seria uma promessa que a tabela não tem como cumprir — ela
91
+ * só tem em mãos a página atual.
92
+ */
93
+ const selectionColumn = <T extends RowData>(
94
+ selectAllLabel: string,
95
+ selectRowLabel: string,
96
+ ): ColumnDef<DataTableFeatures, T, unknown> => ({
97
+ id: "select",
98
+ meta: { shrink: true },
99
+ enableHiding: false,
100
+ enableSorting: false,
101
+ header: ({ table }) => (
102
+ <Checkbox
103
+ aria-label={selectAllLabel}
104
+ checked={
105
+ table.getIsAllPageRowsSelected()
106
+ ? true
107
+ : table.getIsSomePageRowsSelected()
108
+ ? "indeterminate"
109
+ : false
110
+ }
111
+ onCheckedChange={(marcado) => table.toggleAllPageRowsSelected(marcado === true)}
112
+ />
113
+ ),
114
+ cell: ({ row }) => (
115
+ <Checkbox
116
+ aria-label={selectRowLabel}
117
+ checked={row.getIsSelected()}
118
+ disabled={!row.getCanSelect()}
119
+ onCheckedChange={(marcado) => row.toggleSelected(marcado === true)}
120
+ />
121
+ ),
122
+ });
123
+
78
124
  interface DataTableProps<T extends RowData> {
79
125
  columns: ColumnDef<DataTableFeatures, T, unknown>[];
80
126
  data: T[];
@@ -103,6 +149,23 @@ interface DataTableProps<T extends RowData> {
103
149
  onSortingChange?: OnChangeFn<SortingState>;
104
150
  /** Habilita ordenação (padrão true). Desligue quando o backend ainda não ordena. */
105
151
  enableSorting?: boolean;
152
+ /**
153
+ * Liga a seleção de linhas — uma coluna de caixas antes das outras.
154
+ *
155
+ * A função existe para a linha que **não** pode entrar: numa fatura, a
156
+ * parcela já paga não se paga de novo. Desabilitar a caixa é melhor que
157
+ * escondê-la, porque a coluna continua alinhada e a linha continua contando
158
+ * como linha.
159
+ */
160
+ enableRowSelection?: boolean | ((row: T) => boolean);
161
+ /**
162
+ * O que está selecionado, a cada mudança.
163
+ *
164
+ * O `clear` vem junto de propósito: quem age sobre a seleção precisa desfazê-la
165
+ * depois — pagou as seis parcelas, as caixas têm de esvaziar. Guardar a função
166
+ * é mais simples que a tabela adivinhar quando a ação terminou.
167
+ */
168
+ onSelectionChange?: (rows: T[], clear: () => void) => void;
106
169
  /**
107
170
  * Filtros dinâmicos, como o `useListQuery` os devolve. Sem esta prop o botão
108
171
  * não aparece — listagem sem catálogo continua funcionando igual.
@@ -372,6 +435,8 @@ export function DataTable<T extends RowData>({
372
435
  sorting,
373
436
  onSortingChange,
374
437
  enableSorting = true,
438
+ enableRowSelection,
439
+ onSelectionChange,
375
440
  filters,
376
441
  hiddenFilterFields,
377
442
  emptyMessage,
@@ -453,15 +518,34 @@ export function DataTable<T extends RowData>({
453
518
  ? { pageIndex: 0, pageSize: Math.max(data.length, 1) }
454
519
  : internalPagination;
455
520
 
521
+ const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
522
+
523
+ /*
524
+ A coluna das caixas entra na frente das outras, e só existe com a seleção
525
+ ligada: uma coluna a mais em toda tabela do sistema custaria 40px de largura
526
+ a telas que nunca vão selecionar nada.
527
+
528
+ `enableHiding: false` porque ela não é dado — esconder a caixa deixaria a
529
+ seleção ativa e invisível, que é o pior estado possível.
530
+ */
531
+ const colunas = useMemo(
532
+ () =>
533
+ enableRowSelection
534
+ ? [selectionColumn<T>(t("table.selectAll"), t("table.selectRow")), ...columns]
535
+ : columns,
536
+ [columns, enableRowSelection, t],
537
+ );
538
+
456
539
  const table = useTable({
457
540
  features: dataTableFeatures,
458
541
  data,
459
- columns,
542
+ columns: colunas,
460
543
  getRowId: getRowId ? (row) => getRowId(row) : undefined,
461
544
  state: {
462
545
  columnVisibility,
463
546
  sorting: manualSorting ? sorting : internalSorting,
464
547
  pagination: manualPagination ? pagination : paginacaoLocal,
548
+ rowSelection,
465
549
  },
466
550
  onColumnVisibilityChange: (updater) => {
467
551
  const next =
@@ -472,6 +556,11 @@ export function DataTable<T extends RowData>({
472
556
  .map(([k]) => k),
473
557
  );
474
558
  },
559
+ onRowSelectionChange: setRowSelection,
560
+ enableRowSelection:
561
+ typeof enableRowSelection === "function"
562
+ ? (row: { original: T }) => enableRowSelection(row.original)
563
+ : enableRowSelection,
475
564
  onSortingChange: manualSorting ? onSortingChange : setInternalSorting,
476
565
  onPaginationChange: manualPagination
477
566
  ? onPaginationChange
@@ -482,6 +571,28 @@ export function DataTable<T extends RowData>({
482
571
  ...(manualPagination ? { rowCount: rowCount ?? 0 } : {}),
483
572
  });
484
573
 
574
+ /*
575
+ A tela é avisada num efeito, e não dentro do `onRowSelectionChange`.
576
+
577
+ O handler roda **antes** de o estado entrar, então ali a tabela ainda
578
+ responde a seleção antiga — e a tela receberia sempre uma seleção atrasada
579
+ em um clique, que é o tipo de erro que só aparece quando alguém confere o
580
+ total do que selecionou.
581
+ */
582
+ const selecionadas = table.getSelectedRowModel().rows.map((row) => row.original);
583
+ const avisar = useRef(onSelectionChange);
584
+
585
+ useEffect(() => {
586
+ avisar.current = onSelectionChange;
587
+ }, [onSelectionChange]);
588
+
589
+ useEffect(() => {
590
+ avisar.current?.(selecionadas as T[], () => setRowSelection({}));
591
+ // A dependência é o **estado** da seleção, e não a lista derivada: esta é
592
+ // um array novo a cada render, e o efeito rodaria sempre.
593
+ // eslint-disable-next-line react-hooks/exhaustive-deps
594
+ }, [rowSelection]);
595
+
485
596
  /*
486
597
  Linha de fechamento: existe só se alguma coluna visível declarar `footer`.
487
598
 
@@ -35,5 +35,6 @@ export * from "./simple-select";
35
35
  export * from "./sonner";
36
36
  export * from "./spinner";
37
37
  export * from "./switch";
38
+ export * from "./tab-shortcuts";
38
39
  export * from "./table";
39
40
  export * from "./tabs";
@@ -0,0 +1,77 @@
1
+ "use client";
2
+
3
+ import type { JSX } from "react";
4
+
5
+ import { MAX_TAB_SHORTCUTS, useShortcut } from "#core/hooks/use-shortcut";
6
+
7
+ export interface TabShortcutsProps {
8
+ /** Os valores das abas, na ordem em que aparecem. */
9
+ values: readonly string[];
10
+ onSelect: (value: string) => void;
11
+ }
12
+
13
+ /**
14
+ * O rótulo do atalho de uma aba, para a dica que a própria aba mostra.
15
+ *
16
+ * `⌥` no Mac e `Alt` no resto: é a mesma tecla física, mas ninguém procura
17
+ * "Alt" num teclado da Apple. Fora do navegador (SSR) responde `Alt`, que é o
18
+ * que o primeiro render precisa escrever — a correção vem no cliente.
19
+ */
20
+ export const tabShortcutLabel = (index: number): string => {
21
+ const mac =
22
+ typeof navigator !== "undefined" && /Mac|iPhone|iPad/.test(navigator.platform);
23
+
24
+ return `${mac ? "⌥" : "Alt"} ${index + 1}`;
25
+ };
26
+
27
+ /**
28
+ * Um atalho, uma instância.
29
+ *
30
+ * O `useShortcut` aceita uma lista de teclas mas chama o handler sem dizer
31
+ * **qual** disparou. Com um componente por aba, cada instância fica com a sua
32
+ * tecla e a sua ação, e nenhum hook roda dentro de laço — que é o que a regra
33
+ * dos hooks proíbe.
34
+ */
35
+ const TabShortcut = ({
36
+ digit,
37
+ value,
38
+ onSelect,
39
+ }: {
40
+ digit: number;
41
+ value: string;
42
+ onSelect: (value: string) => void;
43
+ }): null => {
44
+ /*
45
+ Por `code`, e não por `key`: no macOS o `Option` + número não produz o
46
+ número — `⌥1` chega como `¡` e `⌥2` como `™`. Declarado como "1", o atalho
47
+ funcionava no Windows e falhava no Mac, que é o pior desfecho possível.
48
+ */
49
+ useShortcut(`Digit${digit}`, () => onSelect(value), { alt: true, byCode: true });
50
+
51
+ return null;
52
+ };
53
+
54
+ /**
55
+ * `Alt` + `1..9` troca de aba. Não desenha nada.
56
+ *
57
+ * Com `Alt`, e não a tecla sozinha: os números precisam continuar sendo
58
+ * digitáveis, e o `1` sem modificador já é o atalho de abrir cadastro — dois
59
+ * donos para a mesma tecla é o conflito que só aparece na tela onde os dois
60
+ * existem.
61
+ *
62
+ * A ordem das abas é a fonte da numeração: passe a mesma lista que desenha os
63
+ * gatilhos, e não uma cópia. Duas fontes para a mesma ordem é o jeito garantido
64
+ * de `Alt + 3` abrir a aba errada no dia em que alguém reordenar a barra.
65
+ */
66
+ export const TabShortcuts = ({ values, onSelect }: TabShortcutsProps): JSX.Element => (
67
+ <>
68
+ {values.slice(0, MAX_TAB_SHORTCUTS).map((value, index) => (
69
+ <TabShortcut
70
+ key={value}
71
+ digit={index + 1}
72
+ value={value}
73
+ onSelect={onSelect}
74
+ />
75
+ ))}
76
+ </>
77
+ );
@@ -49,7 +49,7 @@ export function useLoginFlow(): UseLoginFlowResult {
49
49
  const router = useRouter();
50
50
  const { notify } = useToast();
51
51
  const { t } = useI18n();
52
- const { run, loading, error, setError } = useRequest();
52
+ const { run, loading, error, setError } = useRequest({ silent: true });
53
53
  const { refreshProfile } = useAuth();
54
54
 
55
55
  const [step, setStep] = useState<LoginStep>("credentials");
@@ -1,6 +1,6 @@
1
1
  "use client";
2
2
 
3
- import { AlertTriangle, CheckCircle2, Info, XCircle } from "lucide-react";
3
+ import { AlertTriangle, CheckCircle2, Info, X, XCircle } from "lucide-react";
4
4
  import type { JSX } from "react";
5
5
 
6
6
  import { formatDateTime } from "#core/_utils/format";
@@ -23,26 +23,37 @@ const ICONS: Record<NotificationType, JSX.Element> = {
23
23
  export interface NotificationItemProps {
24
24
  notification: UserNotification;
25
25
  onSelect: (notification: UserNotification) => void;
26
+ /** Tira esta notificação da caixa. */
27
+ onDismiss: (id: string) => void;
26
28
  }
27
29
 
28
30
  export function NotificationItem({
29
31
  notification,
30
32
  onSelect,
33
+ onDismiss,
31
34
  }: NotificationItemProps): JSX.Element {
32
- const { locale } = useI18n();
35
+ const { locale, t } = useI18n();
33
36
 
34
37
  return (
35
- <button
36
- type="button"
37
- onClick={() => onSelect(notification)}
38
+ /*
39
+ O `×` é irmão do botão, e não filho: botão dentro de botão é HTML
40
+ inválido, e o clique de um dispararia o outro — descartar abriria a rota
41
+ da notificação no caminho.
42
+ */
43
+ <div
38
44
  className={cn(
39
- "flex w-full gap-3 border-b border-border px-4 py-3 text-left transition-colors hover:bg-accent",
45
+ "group/notice relative border-b border-border transition-colors hover:bg-accent",
40
46
  !notification.read && "bg-accent/40",
41
47
  )}
42
48
  >
43
- <span className="mt-0.5 shrink-0">{ICONS[notification.type]}</span>
49
+ <button
50
+ type="button"
51
+ onClick={() => onSelect(notification)}
52
+ className="flex w-full gap-3 px-4 py-3 pr-10 text-left"
53
+ >
54
+ <span className="mt-0.5 shrink-0">{ICONS[notification.type]}</span>
44
55
 
45
- <span className="min-w-0 flex-1">
56
+ <span className="min-w-0 flex-1">
46
57
  <span className="flex items-center gap-2">
47
58
  <span
48
59
  className={cn(
@@ -63,7 +74,20 @@ export function NotificationItem({
63
74
  <span className="mt-1 block text-[11px] text-muted-foreground">
64
75
  {formatDateTime(notification.createdAt, locale)}
65
76
  </span>
66
- </span>
67
- </button>
77
+ </span>
78
+ </button>
79
+
80
+ {/* Só no hover: um `×` fixo em cada linha vira ruído numa lista de vinte,
81
+ e o gesto de descartar não é o que se procura ao abrir o sino. */}
82
+ <button
83
+ type="button"
84
+ onClick={() => onDismiss(notification.id)}
85
+ aria-label={t("notifications.dismiss")}
86
+ title={t("notifications.dismiss")}
87
+ className="absolute right-2 top-3 hidden rounded p-1 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground group-hover/notice:block"
88
+ >
89
+ <X className="h-3.5 w-3.5" />
90
+ </button>
91
+ </div>
68
92
  );
69
93
  }
@@ -20,6 +20,10 @@ export interface UseNotificationsResult {
20
20
  fetch: () => Promise<void>;
21
21
  markAsRead: (id: string) => Promise<void>;
22
22
  markAllAsRead: () => Promise<void>;
23
+ /** Tira uma da caixa deste usuário. */
24
+ dismiss: (id: string) => Promise<void>;
25
+ /** Limpa as já lidas — o "não deixa acumular". */
26
+ dismissRead: () => Promise<void>;
23
27
  }
24
28
 
25
29
  export function useNotifications(): UseNotificationsResult {
@@ -74,5 +78,32 @@ export function useNotifications(): UseNotificationsResult {
74
78
  await run(() => notificationsService.markAllAsRead());
75
79
  }, [run]);
76
80
 
77
- return { items, unread, loading, fetch, markAsRead, markAllAsRead };
81
+ /*
82
+ A lista recarrega em vez de tirar o item na mão: a paginação é do servidor,
83
+ e remover local deixaria a página com um registro a menos até o próximo
84
+ fetch — some uma notificação da contagem sem que ela tenha sido descartada.
85
+ */
86
+ const dismiss = useCallback(
87
+ async (id: string): Promise<void> => {
88
+ await notificationsService.dismiss(id);
89
+ await fetch();
90
+ },
91
+ [fetch],
92
+ );
93
+
94
+ const dismissRead = useCallback(async (): Promise<void> => {
95
+ await notificationsService.dismissRead();
96
+ await fetch();
97
+ }, [fetch]);
98
+
99
+ return {
100
+ items,
101
+ unread,
102
+ loading,
103
+ fetch,
104
+ markAsRead,
105
+ markAllAsRead,
106
+ dismiss,
107
+ dismissRead,
108
+ };
78
109
  }
@@ -18,7 +18,7 @@ import { useNotifications } from "#core/features/notifications/hooks/use-notific
18
18
  import type { UserNotification } from "#core/features/notifications/services/notifications.service";
19
19
  import { cn } from "#core/lib/utils";
20
20
 
21
- type Tab = "all" | "unread";
21
+ type Tab = "all" | "unread" | "read";
22
22
 
23
23
  /** Acima disso o número não cabe no badge sem empurrar o layout. */
24
24
  const BADGE_LIMIT = 99;
@@ -26,11 +26,18 @@ const BADGE_LIMIT = 99;
26
26
  export function NotificationsCenter(): JSX.Element {
27
27
  const { t } = useI18n();
28
28
  const router = useRouter();
29
- const { items, unread, loading, markAsRead, markAllAsRead } =
29
+ const { items, unread, loading, markAsRead, markAllAsRead, dismiss, dismissRead } =
30
30
  useNotifications();
31
31
  const [tab, setTab] = useState<Tab>("all");
32
32
 
33
- const visible = tab === "unread" ? items.filter((i) => !i.read) : items;
33
+ const visible =
34
+ tab === "unread"
35
+ ? items.filter((i) => !i.read)
36
+ : tab === "read"
37
+ ? items.filter((i) => i.read)
38
+ : items;
39
+
40
+ const lidas = items.filter((i) => i.read).length;
34
41
 
35
42
  const onSelect = (notification: UserNotification): void => {
36
43
  if (!notification.read) {
@@ -63,7 +70,7 @@ export function NotificationsCenter(): JSX.Element {
63
70
  <DropdownMenuContent align="end" className="w-96 p-0">
64
71
  <div className="flex items-center justify-between border-b border-border px-4 py-2">
65
72
  <div className="flex gap-3">
66
- {(["all", "unread"] as const).map((value) => (
73
+ {(["all", "unread", "read"] as const).map((value) => (
67
74
  <button
68
75
  key={value}
69
76
  type="button"
@@ -75,21 +82,35 @@ export function NotificationsCenter(): JSX.Element {
75
82
  : "border-transparent text-muted-foreground hover:text-foreground",
76
83
  )}
77
84
  >
78
- {value === "all"
79
- ? t("notifications.all")
80
- : t("notifications.unread")}
85
+ {t(`notifications.${value}`)}
81
86
  </button>
82
87
  ))}
83
88
  </div>
84
89
 
85
- <button
86
- type="button"
87
- onClick={() => void markAllAsRead()}
88
- disabled={unread === 0}
89
- className="text-xs font-medium text-primary hover:underline disabled:cursor-not-allowed disabled:text-muted-foreground disabled:no-underline"
90
- >
91
- {t("notifications.markAllRead")}
92
- </button>
90
+ {/*
91
+ A ação do cabeçalho segue a aba. Em "Lidas" o que se quer é
92
+ esvaziar, e "marcar todas como lidas" ali não faria nada — botão que
93
+ não pode fazer efeito é botão que ensina a pessoa a desconfiar.
94
+ */}
95
+ {tab === "read" ? (
96
+ <button
97
+ type="button"
98
+ onClick={() => void dismissRead()}
99
+ disabled={lidas === 0}
100
+ className="text-xs font-medium text-primary hover:underline disabled:cursor-not-allowed disabled:text-muted-foreground disabled:no-underline"
101
+ >
102
+ {t("notifications.clearRead")}
103
+ </button>
104
+ ) : (
105
+ <button
106
+ type="button"
107
+ onClick={() => void markAllAsRead()}
108
+ disabled={unread === 0}
109
+ className="text-xs font-medium text-primary hover:underline disabled:cursor-not-allowed disabled:text-muted-foreground disabled:no-underline"
110
+ >
111
+ {t("notifications.markAllRead")}
112
+ </button>
113
+ )}
93
114
  </div>
94
115
 
95
116
  <div className="max-h-96 overflow-y-auto">
@@ -110,6 +131,7 @@ export function NotificationsCenter(): JSX.Element {
110
131
  key={notification.id}
111
132
  notification={notification}
112
133
  onSelect={onSelect}
134
+ onDismiss={(id) => void dismiss(id)}
113
135
  />
114
136
  ))}
115
137
  </div>
@@ -35,4 +35,14 @@ export const notificationsService = {
35
35
  markAllAsRead() {
36
36
  return api.patch("/notifications/read-all", {}).then((r) => r.data);
37
37
  },
38
+
39
+ /** Tira uma da caixa de quem chamou — não apaga para os outros. */
40
+ dismiss(id: string) {
41
+ return api.delete(`/notifications/${id}`).then(() => undefined);
42
+ },
43
+
44
+ /** Limpa as já lidas. Só elas: o não lido é o que não pode se perder. */
45
+ dismissRead() {
46
+ return api.delete("/notifications/read").then(() => undefined);
47
+ },
38
48
  };
@@ -22,7 +22,7 @@ export function ForcePasswordChange({
22
22
  }: {
23
23
  reason?: "expired" | "provisional";
24
24
  }): JSX.Element {
25
- const { run, loading, error } = useRequest();
25
+ const { run, loading, error } = useRequest({ silent: true });
26
26
  const { logout } = useAuth();
27
27
  const { t } = useI18n();
28
28
 
@@ -53,10 +53,12 @@ export function ProfileScreen(): JSX.Element {
53
53
  // `error` vinha sendo ignorado: o `useRequest` guarda a mensagem do servidor
54
54
  // mas não notifica, então um 400 de validação ficava invisível — a tela não
55
55
  // salvava e nada dizia por quê.
56
- const { run, loading, error, setError } = useRequest();
56
+ // Os dois calam o toast: esta tela desenha `error` acima do formulário, e
57
+ // a troca de senha desenha o dela dentro do bloco de senha.
58
+ const { run, loading, error, setError } = useRequest({ silent: true });
57
59
  // useRequest próprio da troca de senha: o `run` compartilhado da tela zeraria
58
60
  // esse erro a cada outra ação (salvar perfil, trocar avatar).
59
- const password = useRequest();
61
+ const password = useRequest({ silent: true });
60
62
  const { t } = useI18n();
61
63
 
62
64
  const [avatar, setAvatar] = useState<string | null>(null);
@@ -35,7 +35,7 @@ export function ResetPasswordScreen(): JSX.Element {
35
35
  const router = useRouter();
36
36
  const params = useSearchParams();
37
37
  const token = params.get("token") ?? "";
38
- const { run, loading, error } = useRequest();
38
+ const { run, loading, error } = useRequest({ silent: true });
39
39
  const { t } = useI18n();
40
40
  const [done, setDone] = useState(false);
41
41
  const [check, setCheck] = useState<PasswordTokenCheck | null>(null);
@@ -21,8 +21,6 @@ export interface Shortcut {
21
21
  where: string;
22
22
  /** Quando ele **não** dispara — a metade que evita surpresa. */
23
23
  caveat: string;
24
- /** Observação de teclado, quando a tecla tem outro nome em algum sistema. */
25
- note?: string;
26
24
  icon: React.ComponentType<{ className?: string }>;
27
25
  }
28
26
 
@@ -88,9 +86,6 @@ export function ShortcutCard({ shortcut }: ShortcutCardProps): JSX.Element {
88
86
  <Badge variant="secondary" className="w-fit font-normal">
89
87
  {t(shortcut.caveat)}
90
88
  </Badge>
91
- {shortcut.note && (
92
- <p className="text-xs text-muted-foreground">{t(shortcut.note)}</p>
93
- )}
94
89
  </div>
95
90
  </Card>
96
91
  );
@@ -5,6 +5,7 @@ import {
5
5
  Keyboard,
6
6
  Maximize2,
7
7
  PlusSquare,
8
+ Rows3,
8
9
  XSquare,
9
10
  } from "lucide-react";
10
11
  import type { JSX } from "react";
@@ -15,8 +16,10 @@ import {
15
16
  ShortcutCard,
16
17
  } from "#core/features/shortcuts/components/shortcut-card";
17
18
  import {
19
+ ALT_KEY_LABELS,
18
20
  CREATE_SHORTCUT_KEYS,
19
- EXPAND_SHORTCUT_LABEL,
21
+ EXPAND_SHORTCUT_KEY,
22
+ TAB_SHORTCUT_DIGITS,
20
23
  } from "#core/hooks/use-shortcut";
21
24
 
22
25
  /**
@@ -38,12 +41,20 @@ const SHORTCUTS: Shortcut[] = [
38
41
  caveat: "shortcuts.create.caveat",
39
42
  icon: PlusSquare,
40
43
  },
44
+ {
45
+ name: "shortcuts.tabs.name",
46
+ // `Alt` e `⌥` são a mesma tecla: viram duas combinações e o card escreve
47
+ // "ou" entre elas, em vez de a diferença virar nota de rodapé.
48
+ combos: ALT_KEY_LABELS.map((alt) => [alt, TAB_SHORTCUT_DIGITS]),
49
+ where: "shortcuts.tabs.where",
50
+ caveat: "shortcuts.tabs.caveat",
51
+ icon: Rows3,
52
+ },
41
53
  {
42
54
  name: "shortcuts.expand.name",
43
- combos: [EXPAND_SHORTCUT_LABEL.split(" + ")],
55
+ combos: ALT_KEY_LABELS.map((alt) => [alt, EXPAND_SHORTCUT_KEY]),
44
56
  where: "shortcuts.expand.where",
45
57
  caveat: "shortcuts.expand.caveat",
46
- note: "shortcuts.expand.note",
47
58
  icon: Maximize2,
48
59
  },
49
60
  {
@@ -1,7 +1,7 @@
1
1
  "use client";
2
2
 
3
3
  import { AxiosError } from "axios";
4
- import { Dispatch, SetStateAction, useCallback, useState } from "react";
4
+ import { Dispatch, SetStateAction, useCallback, useEffect, useRef, useState } from "react";
5
5
 
6
6
  import { useI18n, useToast } from "#core/contexts";
7
7
 
@@ -34,6 +34,18 @@ export interface RunOptions {
34
34
  * "Registro criado" (ex.: "Link de primeiro acesso enviado").
35
35
  */
36
36
  success?: RequestOperation | string;
37
+ /**
38
+ * Cala o toast de erro — para a tela que **já desenha** o `error` na página.
39
+ *
40
+ * O padrão é avisar, e é o certo: falha silenciosa é o pior desfecho
41
+ * possível de uma escrita. A pessoa clica em "Pagar", nada acontece, e não há
42
+ * como distinguir "o servidor recusou" de "o botão está quebrado" — as duas
43
+ * hipóteses levam a caminhos diferentes e nenhuma delas é investigar o log.
44
+ *
45
+ * O login é o caso legítimo do contrário: ele mostra "Credenciais inválidas"
46
+ * dentro do formulário, e o toast por cima diria a mesma coisa duas vezes.
47
+ */
48
+ silent?: boolean;
37
49
  /**
38
50
  * Reage ao erro com o `errorCode` em mãos — para quando a tela precisa fazer
39
51
  * algo além de mostrar a mensagem (ex.: voltar ao passo de credenciais
@@ -59,14 +71,33 @@ export interface UseRequestResult {
59
71
  * Hook genérico para chamadas assíncronas com estados de loading/erro.
60
72
  * Ex.: const { run, loading, error } = useRequest();
61
73
  * await run(() => authService.login(email, senha));
74
+ *
75
+ * Os `defaults` valem para todas as chamadas daquele hook, e cada `run` os
76
+ * sobrescreve. É o que evita repetir `silent: true` em dez chamadas de uma tela
77
+ * que desenha o erro na própria página — e repetir dez vezes é como uma delas
78
+ * fica para trás.
62
79
  */
63
- export function useRequest(): UseRequestResult {
80
+ export function useRequest(defaults: RunOptions = {}): UseRequestResult {
64
81
  const { t } = useI18n();
65
82
  const { notify } = useToast();
66
83
  const [loading, setLoading] = useState(false);
84
+ /*
85
+ Os padrões entram por ref, e não pela lista de dependências do `run`.
86
+
87
+ A tela escreve `useRequest({ silent: true })`, e um literal é objeto novo a
88
+ cada render: pela dependência, o `run` mudaria de identidade sempre, e todo
89
+ `useCallback`/`useEffect` que depende dele — a carga de uma listagem, por
90
+ exemplo — recarregaria em laço. O sintoma seria a tela piscando sozinha, a
91
+ quilômetros da causa.
92
+ */
93
+ const defaultsRef = useRef(defaults);
67
94
  const [error, setError] = useState<string | null>(null);
68
95
  const [errorCode, setErrorCode] = useState<string | null>(null);
69
96
 
97
+ useEffect(() => {
98
+ defaultsRef.current = defaults;
99
+ }, [defaults]);
100
+
70
101
  /**
71
102
  * Ordem de preferência da mensagem:
72
103
  * 1. tradução do `errorCode` — texto do produto, no idioma do usuário;
@@ -103,13 +134,14 @@ export function useRequest(): UseRequestResult {
103
134
  );
104
135
 
105
136
  const run = useCallback(
106
- async <T>(fn: () => Promise<T>, options?: RunOptions): Promise<T | null> => {
137
+ async <T>(fn: () => Promise<T>, call?: RunOptions): Promise<T | null> => {
138
+ const options = { ...defaultsRef.current, ...call };
107
139
  setLoading(true);
108
140
  setError(null);
109
141
  setErrorCode(null);
110
142
  try {
111
143
  const result = await fn();
112
- if (options?.success) {
144
+ if (options.success) {
113
145
  notify(successMessage(options.success), "success");
114
146
  }
115
147
  return result;
@@ -120,7 +152,17 @@ export function useRequest(): UseRequestResult {
120
152
  const code = body?.errorCode ?? null;
121
153
  setError(message);
122
154
  setErrorCode(code);
123
- options?.onError?.({ code, message });
155
+
156
+ /*
157
+ O aviso vem antes do `onError` de propósito: quem passa o callback
158
+ costuma navegar ou fechar um modal ali dentro, e o toast disparado
159
+ depois disso apareceria sobre uma tela que já mudou.
160
+ */
161
+ if (!options.silent) {
162
+ notify(message, "error");
163
+ }
164
+ options.onError?.({ code, message });
165
+
124
166
  return null;
125
167
  } finally {
126
168
  setLoading(false);
@@ -47,6 +47,26 @@ export const EXPAND_SHORTCUT_KEY = "Enter";
47
47
  /** Como ele se apresenta ao usuário. */
48
48
  export const EXPAND_SHORTCUT_LABEL = "Alt + Enter";
49
49
 
50
+ /**
51
+ * O modificador do `Alt`, escrito das duas formas que os teclados usam.
52
+ *
53
+ * `Alt` e `⌥` são a **mesma tecla física**, e é por isso que aparecem como
54
+ * combinações alternativas na tela de atalhos em vez de virarem uma nota de
55
+ * rodapé: quem tem um teclado da Apple procura o símbolo, não a palavra.
56
+ */
57
+ export const ALT_KEY_LABELS = ["Alt", "⌥"] as const;
58
+
59
+ /**
60
+ * Quantas abas o atalho numera.
61
+ *
62
+ * Nove porque `Alt + 0` não segue a sequência, e porque ninguém conta abas além
63
+ * disso sem olhar.
64
+ */
65
+ export const MAX_TAB_SHORTCUTS = 9;
66
+
67
+ /** Como o atalho de abas se apresenta: `Alt` + `1…9`. */
68
+ export const TAB_SHORTCUT_DIGITS = `1…${MAX_TAB_SHORTCUTS}`;
69
+
50
70
  export interface ShortcutOptions {
51
71
  /**
52
72
  * Exige duas batidas seguidas dentro de meio segundo.
@@ -85,6 +105,28 @@ export interface ShortcutOptions {
85
105
  * `false` quando a tecla sem ele significar outra coisa.
86
106
  */
87
107
  shift?: boolean;
108
+ /**
109
+ * Compara a **posição física** da tecla (`event.code`), e não o caractere.
110
+ *
111
+ * Existe por causa do `Alt`: no macOS, `Option` + número **não** produz o
112
+ * número — produz um símbolo. `⌥1` chega como `¡`, `⌥2` como `™`, `⌥3` como
113
+ * `£`, e o atalho declarado como `"1"` nunca dispara. No Windows e no Linux
114
+ * ele dispararia, o que é pior que não funcionar em lugar nenhum: quebra só
115
+ * na máquina de quem usa Mac.
116
+ *
117
+ * `event.code` é imune a isso e a layout: a tecla ao lado do `Q` é `KeyW` no
118
+ * QWERTY e no AZERTY, e o `1` é `Digit1` com ou sem modificador.
119
+ *
120
+ * Em troca, ele **ignora o layout de propósito** — e é justamente por isso
121
+ * que não é o padrão. Um atalho de letra declarado por `code` cai na tecla
122
+ * errada de quem usa Dvorak, enquanto por `key` ele segue a letra que a
123
+ * pessoa vê impressa. Use `byCode` para dígito com modificador; para letra,
124
+ * continue em `key`.
125
+ *
126
+ * Os valores são os do padrão: `Digit1`..`Digit9`, `KeyA`..`KeyZ`,
127
+ * `Numpad1`, `Enter`, `Space`.
128
+ */
129
+ byCode?: boolean;
88
130
  /**
89
131
  * Deixa o atalho valer também com o cursor dentro de um campo.
90
132
  *
@@ -146,6 +188,7 @@ export function useShortcut(
146
188
  shift,
147
189
  allowInDialog = false,
148
190
  allowWhileTyping = false,
191
+ byCode = false,
149
192
  } = options;
150
193
 
151
194
  // Serializado para o efeito não se reinscrever a cada render quando a tela
@@ -179,7 +222,12 @@ export function useShortcut(
179
222
  const accepted = keySignature.split("\u0000");
180
223
 
181
224
  const onKeyDown = (event: KeyboardEvent): void => {
182
- if (!accepted.includes(event.key)) {
225
+ // A mesma leitura serve para a comparação e para a janela do duplo: são
226
+ // "a mesma tecla", e ler uma por `code` e outra por `key` faria a segunda
227
+ // batida de um atalho por posição nunca casar com a primeira.
228
+ const pressed = byCode ? event.code : event.key;
229
+
230
+ if (!accepted.includes(pressed)) {
183
231
  return;
184
232
  }
185
233
  if (event.altKey !== alt) {
@@ -203,10 +251,10 @@ export function useShortcut(
203
251
  if (double) {
204
252
  const now = Date.now();
205
253
  const previous = lastPressRef.current;
206
- lastPressRef.current = { key: event.key, at: now };
254
+ lastPressRef.current = { key: pressed, at: now };
207
255
 
208
256
  if (
209
- previous.key !== event.key ||
257
+ previous.key !== pressed ||
210
258
  now - previous.at > DOUBLE_PRESS_WINDOW_MS
211
259
  ) {
212
260
  return;
@@ -244,6 +292,7 @@ export function useShortcut(
244
292
  shift,
245
293
  allowInDialog,
246
294
  allowWhileTyping,
295
+ byCode,
247
296
  ]);
248
297
  }
249
298
 
@@ -142,12 +142,17 @@ export const en: Messages = {
142
142
  where: "On any listing with a create button.",
143
143
  caveat: "Does not fire inside a text field or while a dialog is open",
144
144
  },
145
+ tabs: {
146
+ name: "Switch tab",
147
+ where:
148
+ "On any screen with tabs, the number opens the tab in that position — 1 is the first.",
149
+ caveat: "Does not fire inside a text field or with a modal open",
150
+ },
145
151
  expand: {
146
152
  name: "Expand the dialog",
147
153
  where:
148
154
  "With a dialog open, toggles between normal and expanded size. Works anywhere in the dialog, like Esc.",
149
155
  caveat: "Works in any field, and does not submit the form",
150
- note: "On a Mac, Alt is the Option key (⌥) — most keyboards print both labels on it.",
151
156
  },
152
157
  submit: {
153
158
  name: "Submit the form",
@@ -170,6 +175,8 @@ export const en: Messages = {
170
175
  },
171
176
  table: {
172
177
  columns: "Columns",
178
+ selectAll: "Select all rows on this page",
179
+ selectRow: "Select this row",
173
180
  columnsButton: "Columns",
174
181
  columnsTitle: "Configure column visibility",
175
182
  columnsHint: "Select the columns you want to see in the table.",
@@ -252,6 +259,9 @@ export const en: Messages = {
252
259
  title: "Notifications",
253
260
  all: "All",
254
261
  unread: "Unread",
262
+ read: "Read",
263
+ clearRead: "Clear read",
264
+ dismiss: "Remove from list",
255
265
  markAllRead: "Mark all as read",
256
266
  empty: "No notifications",
257
267
  },
@@ -144,12 +144,17 @@ export const pt = {
144
144
  where: "Em qualquer listagem que tenha o botão de cadastrar.",
145
145
  caveat: "Não dispara dentro de um campo de texto nem com um modal aberto",
146
146
  },
147
+ tabs: {
148
+ name: "Trocar de aba",
149
+ where:
150
+ "Em qualquer tela com abas, o número abre a aba naquela posição — 1 é a primeira.",
151
+ caveat: "Não dispara dentro de um campo de texto nem com um modal aberto",
152
+ },
147
153
  expand: {
148
154
  name: "Ampliar o modal",
149
155
  where:
150
156
  "Com um modal aberto, alterna entre o tamanho normal e o ampliado. Vale em qualquer lugar do modal, como o Esc.",
151
157
  caveat: "Funciona em qualquer campo, e não envia o formulário junto",
152
- note: "No Mac, o Alt é a tecla Option (⌥) — em boa parte dos teclados ela vem escrita das duas formas.",
153
158
  },
154
159
  submit: {
155
160
  name: "Enviar o formulário",
@@ -172,6 +177,8 @@ export const pt = {
172
177
  },
173
178
  table: {
174
179
  columns: "Colunas",
180
+ selectAll: "Selecionar todas as linhas da página",
181
+ selectRow: "Selecionar esta linha",
175
182
  columnsButton: "Colunas",
176
183
  columnsTitle: "Configurar visualização de colunas",
177
184
  columnsHint: "Selecione as colunas que deseja visualizar na tabela.",
@@ -255,6 +262,9 @@ export const pt = {
255
262
  title: "Notificações",
256
263
  all: "Todas",
257
264
  unread: "Não lidas",
265
+ read: "Lidas",
266
+ clearRead: "Limpar lidas",
267
+ dismiss: "Tirar da lista",
258
268
  markAllRead: "Marcar todas como lidas",
259
269
  empty: "Nenhuma notificação",
260
270
  },
package/src/index.ts CHANGED
@@ -106,10 +106,13 @@ export { useListQuery } from "#core/hooks/use-list-query";
106
106
  export type { RunOptions, UseRequestResult } from "#core/hooks/use-request";
107
107
  export type { ShortcutOptions } from "#core/hooks/use-shortcut";
108
108
  export {
109
+ ALT_KEY_LABELS,
109
110
  CREATE_SHORTCUT_KEYS,
110
111
  CREATE_SHORTCUT_LABEL,
111
112
  EXPAND_SHORTCUT_KEY,
112
113
  EXPAND_SHORTCUT_LABEL,
114
+ MAX_TAB_SHORTCUTS,
115
+ TAB_SHORTCUT_DIGITS,
113
116
  useShortcut,
114
117
  } from "#core/hooks/use-shortcut";
115
118
  export type { TableView, UseTableViewResult } from "#core/hooks/use-table-view";