rl-core-front 0.18.10 → 0.19.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.18.10",
3
+ "version": "0.19.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",
@@ -244,17 +244,17 @@ export interface paths {
244
244
  patch?: never;
245
245
  trace?: never;
246
246
  };
247
- "/auth/password-token/{token}": {
247
+ "/auth/password-token/inspect": {
248
248
  parameters: {
249
249
  query?: never;
250
250
  header?: never;
251
251
  path?: never;
252
252
  cookie?: never;
253
253
  };
254
- /** Verifica a validade do link de senha sem consumi-lo */
255
- get: operations["AuthController_inspectPasswordToken"];
254
+ get?: never;
256
255
  put?: never;
257
- post?: never;
256
+ /** Verifica a validade do link de senha sem consumi-lo */
257
+ post: operations["AuthController_inspectPasswordToken"];
258
258
  delete?: never;
259
259
  options?: never;
260
260
  head?: never;
@@ -838,6 +838,38 @@ export interface paths {
838
838
  patch: operations["NotificationsController_markAsRead"];
839
839
  trace?: never;
840
840
  };
841
+ "/notifications/read": {
842
+ parameters: {
843
+ query?: never;
844
+ header?: never;
845
+ path?: never;
846
+ cookie?: never;
847
+ };
848
+ get?: never;
849
+ put?: never;
850
+ post?: never;
851
+ delete: operations["NotificationsController_dismissRead"];
852
+ options?: never;
853
+ head?: never;
854
+ patch?: never;
855
+ trace?: never;
856
+ };
857
+ "/notifications/{id}": {
858
+ parameters: {
859
+ query?: never;
860
+ header?: never;
861
+ path?: never;
862
+ cookie?: never;
863
+ };
864
+ get?: never;
865
+ put?: never;
866
+ post?: never;
867
+ delete: operations["NotificationsController_dismiss"];
868
+ options?: never;
869
+ head?: never;
870
+ patch?: never;
871
+ trace?: never;
872
+ };
841
873
  "/exports/{handler}": {
842
874
  parameters: {
843
875
  query?: never;
@@ -1148,6 +1180,9 @@ export interface components {
1148
1180
  ForgotPasswordCommand: {
1149
1181
  email: string;
1150
1182
  };
1183
+ InspectPasswordTokenCommand: {
1184
+ token: string;
1185
+ };
1151
1186
  ResetPasswordCommand: {
1152
1187
  token: string;
1153
1188
  newPassword: string;
@@ -1588,6 +1623,13 @@ export interface components {
1588
1623
  sortBy?: string;
1589
1624
  /** @enum {string} */
1590
1625
  sortDir?: "ASC" | "DESC";
1626
+ /**
1627
+ * @description Parâmetros do documento, validados pelo relatório pedido
1628
+ * @example {
1629
+ * "referenceMonth": "2026-09"
1630
+ * }
1631
+ */
1632
+ params?: Record<string, never>;
1591
1633
  };
1592
1634
  ExportAcceptedResponse: {
1593
1635
  /** @example 184 */
@@ -2038,12 +2080,14 @@ export interface operations {
2038
2080
  parameters: {
2039
2081
  query?: never;
2040
2082
  header?: never;
2041
- path: {
2042
- token: string;
2043
- };
2083
+ path?: never;
2044
2084
  cookie?: never;
2045
2085
  };
2046
- requestBody?: never;
2086
+ requestBody: {
2087
+ content: {
2088
+ "application/json": components["schemas"]["InspectPasswordTokenCommand"];
2089
+ };
2090
+ };
2047
2091
  responses: {
2048
2092
  200: {
2049
2093
  headers: {
@@ -3338,6 +3382,42 @@ export interface operations {
3338
3382
  };
3339
3383
  };
3340
3384
  };
3385
+ NotificationsController_dismissRead: {
3386
+ parameters: {
3387
+ query?: never;
3388
+ header?: never;
3389
+ path?: never;
3390
+ cookie?: never;
3391
+ };
3392
+ requestBody?: never;
3393
+ responses: {
3394
+ 204: {
3395
+ headers: {
3396
+ [name: string]: unknown;
3397
+ };
3398
+ content?: never;
3399
+ };
3400
+ };
3401
+ };
3402
+ NotificationsController_dismiss: {
3403
+ parameters: {
3404
+ query?: never;
3405
+ header?: never;
3406
+ path: {
3407
+ id: string;
3408
+ };
3409
+ cookie?: never;
3410
+ };
3411
+ requestBody?: never;
3412
+ responses: {
3413
+ 204: {
3414
+ headers: {
3415
+ [name: string]: unknown;
3416
+ };
3417
+ content?: never;
3418
+ };
3419
+ };
3420
+ };
3341
3421
  ExportController_request: {
3342
3422
  parameters: {
3343
3423
  query?: never;
@@ -3395,7 +3475,7 @@ export interface operations {
3395
3475
  };
3396
3476
  requestBody?: never;
3397
3477
  responses: {
3398
- /** @description A planilha */
3478
+ /** @description O arquivo gerado */
3399
3479
  200: {
3400
3480
  headers: {
3401
3481
  [name: string]: unknown;
@@ -84,10 +84,9 @@ export const authService = {
84
84
 
85
85
  /** Verifica o link de senha antes de exibir o formulário (não consome o token). */
86
86
  inspectPasswordToken(token: string): Promise<PasswordTokenCheck> {
87
+ // No corpo, não na URL: token em path vai parar em log de proxy.
87
88
  return api
88
- .get<PasswordTokenCheck>(
89
- `/auth/password-token/${encodeURIComponent(token)}`,
90
- )
89
+ .post<PasswordTokenCheck>("/auth/password-token/inspect", { token })
91
90
  .then((r) => r.data);
92
91
  },
93
92
 
@@ -18,6 +18,11 @@ const API_ORIGIN = (process.env.NEXT_PUBLIC_API_BASE_URL ?? "").replace(
18
18
  * avatar gravado por versões anteriores e de qualquer endereço externo que
19
19
  * ainda esteja em base.
20
20
  */
21
+ /** Imagem ainda não confirmada por um salvar — vive em `/uploads/<pasta>/pending/`. */
22
+ export function isPendingImagePath(path?: string | null): boolean {
23
+ return /^\/uploads\/[^/]+\/pending\//.test(path ?? "");
24
+ }
25
+
21
26
  export function uploadUrlOf(path?: string | null): string | undefined {
22
27
  if (!path) {
23
28
  return undefined;
@@ -177,7 +177,9 @@ const ADMIN_CHILDREN: NavItem[] = [
177
177
  perm: anyOrTeam("users:read"),
178
178
  },
179
179
  { key: "nav.rbac", href: "/rbac", icon: ShieldCheck, perm: "roles:read:any" },
180
- { key: "nav.logs", href: "/logs", icon: ScrollText, perm: "audit:read:any" },
180
+ // Gerente o log da própria equipe a rota poda pelo mesmo alcance da
181
+ // listagem de usuários.
182
+ { key: "nav.logs", href: "/logs", icon: ScrollText, perm: anyOrTeam("audit:read") },
181
183
  // Logo abaixo do log de requisições, que é o vizinho natural: os dois contam
182
184
  // o que aconteceu no sistema, um por chamada e outro por alteração de dado.
183
185
  // Restrito a administradores — só quem tem audit:read-trail:any (só Super
@@ -8,6 +8,7 @@ import { Button, type ButtonProps } from "#core/components/ui/button";
8
8
  import {
9
9
  EXPAND_SHORTCUT_KEY,
10
10
  EXPAND_SHORTCUT_LABEL,
11
+ SUBMIT_SHORTCUT_KEY,
11
12
  useShortcut,
12
13
  } from "#core/hooks/use-shortcut";
13
14
  import { cn } from "#core/lib/utils";
@@ -428,10 +429,32 @@ const DialogContent = React.forwardRef<
428
429
  );
429
430
  DialogContent.displayName = DialogPrimitive.Content.displayName;
430
431
 
432
+ /**
433
+ * O modal aberto por cima de todos os outros.
434
+ *
435
+ * O Radix pendura cada modal no fim do `body`, então o último no DOM é o que
436
+ * está por cima. É como o formulário sabe se o atalho é dele: com uma
437
+ * confirmação aberta sobre o cadastro, os dois têm formulário e só o de cima
438
+ * pode responder ao `Ctrl + Enter`.
439
+ */
440
+ const topmostDialog = (): Element | null => {
441
+ const open = document.querySelectorAll('[role="dialog"][data-state="open"]');
442
+
443
+ return open[open.length - 1] ?? null;
444
+ };
445
+
431
446
  /**
432
447
  * Corpo do modal como formulário — é o que faz o `Enter` submeter.
433
448
  * O `preventDefault` fica aqui porque, sem ele, o Enter recarrega a página.
434
449
  *
450
+ * E o `Ctrl + Enter` envia de **qualquer lugar** do modal. O Enter sozinho só
451
+ * envia a partir de um campo de texto: num select ele abre a lista, numa
452
+ * caixa de marcar ele marca, num texto longo ele quebra a linha — e quem está
453
+ * com o foco ali ficava sem saída pelo teclado. O atalho passa pelo mesmo
454
+ * caminho do botão Salvar (`requestSubmit` com ele como `submitter`), então
455
+ * a validação do HTML roda igual e o botão desativado — salvando, ou sem
456
+ * permissão — continua mandando.
457
+ *
435
458
  * O `DialogFooter` é separado dos demais filhos e fica **fora da área
436
459
  * rolável**, ainda dentro do `<form>`. As duas metades importam: fora do
437
460
  * scroll porque `sticky` deixa o conteúdo correr por baixo dos botões — com
@@ -449,10 +472,33 @@ const DialogForm = React.forwardRef<
449
472
  const items = React.Children.toArray(children);
450
473
  const footerAt = indexOfChild(items, "DialogFooter");
451
474
  const hasFooter = footerAt >= 0;
475
+ const formRef = React.useRef<HTMLFormElement>(null);
476
+
477
+ const submitFromAnywhere = (): void => {
478
+ const form = formRef.current;
479
+
480
+ if (!form || form.closest('[role="dialog"]') !== topmostDialog()) {
481
+ return;
482
+ }
483
+
484
+ const submitter = form.querySelector<HTMLButtonElement>('button[type="submit"]');
485
+
486
+ if (submitter?.disabled) {
487
+ return;
488
+ }
489
+
490
+ form.requestSubmit(submitter ?? undefined);
491
+ };
492
+
493
+ useShortcut(SUBMIT_SHORTCUT_KEY, submitFromAnywhere, {
494
+ ctrl: true,
495
+ allowInDialog: true,
496
+ allowWhileTyping: true,
497
+ });
452
498
 
453
499
  return (
454
500
  <form
455
- ref={ref}
501
+ ref={mergeRefs(ref, formRef)}
456
502
  onSubmit={(e) => {
457
503
  e.preventDefault();
458
504
  onSubmit?.(e);
@@ -478,6 +524,19 @@ const DialogForm = React.forwardRef<
478
524
  });
479
525
  DialogForm.displayName = "DialogForm";
480
526
 
527
+ /** O ref de quem usa e o nosso, no mesmo elemento. */
528
+ const mergeRefs =
529
+ <T,>(...refs: React.Ref<T>[]) =>
530
+ (node: T | null): void => {
531
+ for (const ref of refs) {
532
+ if (typeof ref === "function") {
533
+ ref(node);
534
+ } else if (ref) {
535
+ (ref as React.MutableRefObject<T | null>).current = node;
536
+ }
537
+ }
538
+ };
539
+
481
540
  const DialogTitle = React.forwardRef<
482
541
  React.ElementRef<typeof DialogPrimitive.Title>,
483
542
  React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
@@ -0,0 +1,36 @@
1
+ "use client";
2
+
3
+ import { HelpCircle } from "lucide-react";
4
+ import type { JSX } from "react";
5
+
6
+ import { HoverTip } from "#core/components/ui/hover-tip";
7
+
8
+ export interface HelpHintProps {
9
+ /** O texto que explica a tela. */
10
+ text: string;
11
+ /** Como o botão se apresenta a quem usa leitor de tela. */
12
+ label: string;
13
+ }
14
+
15
+ /**
16
+ * A explicação da tela, atrás de um ícone.
17
+ *
18
+ * O texto vivia solto acima da tabela, e ali ele é lido uma vez e depois vira
19
+ * uma linha que empurra a lista para baixo todo dia. No ícone ele continua ao
20
+ * alcance de quem chegou agora, sem ocupar espaço de quem já sabe.
21
+ *
22
+ * É um `HoverTip` com o botão de interrogação dentro: abre ao passar o mouse
23
+ * — ler uma explicação não deveria custar um clique — e também no foco, que é
24
+ * como ela chega a quem está no teclado.
25
+ */
26
+ export const HelpHint = ({ text, label }: HelpHintProps): JSX.Element => (
27
+ <HoverTip label={text}>
28
+ <button
29
+ type="button"
30
+ aria-label={label}
31
+ className="flex h-9 w-9 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
32
+ >
33
+ <HelpCircle className="h-4 w-4" />
34
+ </button>
35
+ </HoverTip>
36
+ );
@@ -1,79 +1,105 @@
1
1
  "use client";
2
2
 
3
- import type { FocusEvent, JSX, MouseEvent, ReactNode } from "react";
4
- import { useState } from "react";
5
- import { createPortal } from "react-dom";
3
+ import type { JSX, ReactNode } from "react";
4
+ import { useEffect, useRef, useState } from "react";
5
+
6
+ import {
7
+ Popover,
8
+ PopoverAnchor,
9
+ PopoverContent,
10
+ } from "#core/components/ui/popover";
11
+ import { cn } from "#core/lib/utils";
6
12
 
7
13
  export interface HoverTipProps {
8
14
  /** O que o tooltip diz. Vazio não desenha nada. */
9
15
  label: string;
10
16
  children: ReactNode;
17
+ /** Classe extra do painel — para alargar quando o texto é uma explicação. */
18
+ className?: string;
11
19
  }
12
20
 
13
- /** Onde o rótulo aparece na tela, em coordenada de viewport. */
14
- interface TipPosition {
15
- x: number;
16
- y: number;
17
- }
18
-
19
- /** A folga entre o gatilho e o rótulo. */
20
- const GAP = 6;
21
+ /**
22
+ * Quanto o fechar espera, em milissegundos.
23
+ *
24
+ * Entre o gatilho e o painel existe a folga do `sideOffset`: fechando no
25
+ * primeiro `mouseleave`, o texto sumia no meio do caminho de quem foi até ele
26
+ * para ler inteiro.
27
+ */
28
+ const CLOSE_GRACE_MS = 150;
21
29
 
22
30
  /**
23
31
  * O rótulo que aparece **na hora** ao passar o mouse.
24
32
  *
25
- * Existe pelo mesmo motivo do `HoverLabel` dos gráficos: o `title` do HTML
26
- * aparece depois de cerca de um segundo parado, e um segundo é tempo suficiente
27
- * para quem passou o mouse concluir que não há nada ali e seguir em frente.
28
- * Numa coluna de cinco ícones, isso é a diferença entre descobrir o que cada um
29
- * faz e clicar para descobrir.
33
+ * Existe porque o `title` do HTML aparece depois de cerca de um segundo
34
+ * parado, e um segundo é tempo suficiente para quem passou o mouse concluir
35
+ * que não há nada ali e seguir em frente. Numa coluna de cinco ícones, isso é
36
+ * a diferença entre descobrir o que cada um faz e clicar para descobrir.
37
+ *
38
+ * É o `Popover` do core por baixo, e por isso tem a cara dele — o mesmo
39
+ * painel da explicação de tela (`HelpHint`) e do calendário: um desenho só
40
+ * para tudo que flutua. Do Radix vêm o portal (o painel não é cortado pela
41
+ * borda da tabela nem do modal) e a virada para cima quando não cabe embaixo.
30
42
  *
31
- * Abre **embaixo**, e sai do documento por um portal com posição fixa. As duas
32
- * decisões são a mesma: o `Table` do core envolve a tabela num `overflow-x-auto`
33
- * e, quando um eixo rola, o navegador recorta o outro também — dentro da célula,
34
- * o rótulo da última linha era cortado pela borda da tabela. Abrir à esquerda,
35
- * como já se tentou, cabia na caixa mas cobria o conteúdo da própria linha: na
36
- * lista de receitas, o "Recebido" do botão tapava o "Pendente" da situação, que
37
- * é justamente o dado que se confere antes de clicar.
43
+ * Ancorado, e não gatilhado: um `PopoverTrigger` faria do botão de editar um
44
+ * "abre um diálogo" para o leitor de tela. Aqui quem abre é o mouse e o foco,
45
+ * e o painel se apresenta como `tooltip`.
38
46
  *
39
- * O estado mora aqui dentro e guarda a coordenada. Ele re-renderiza este
40
- * componente e o rótulo não a linha da tabela, que entra por `children` e
41
- * chega pronta de fora.
47
+ * Abre também no foco quem navega com Tab precisa do mesmo texto que o
48
+ * mouse mostra e não rouba o foco ao abrir: aberto pelo mouse, ele levaria
49
+ * o cursor de teclado para longe do botão que a pessoa nem clicou.
42
50
  */
43
- export const HoverTip = ({ label, children }: HoverTipProps): JSX.Element => {
44
- const [position, setPosition] = useState<TipPosition | null>(null);
51
+ export const HoverTip = ({ label, children, className }: HoverTipProps): JSX.Element => {
52
+ const [open, setOpen] = useState(false);
53
+ const closing = useRef<ReturnType<typeof setTimeout> | null>(null);
45
54
 
46
- const show = (event: MouseEvent<HTMLElement> | FocusEvent<HTMLElement>): void => {
47
- const box = event.currentTarget.getBoundingClientRect();
55
+ const show = (): void => {
56
+ if (closing.current) {
57
+ clearTimeout(closing.current);
58
+ closing.current = null;
59
+ }
60
+ setOpen(true);
61
+ };
48
62
 
49
- setPosition({ x: box.left + box.width / 2, y: box.bottom + GAP });
63
+ const hide = (): void => {
64
+ closing.current = setTimeout(() => setOpen(false), CLOSE_GRACE_MS);
50
65
  };
51
66
 
52
- const hide = (): void => setPosition(null);
67
+ // O timer pendente não pode sobreviver ao componente: a linha da tabela
68
+ // some ao excluir, e o `setOpen` chegaria num componente desmontado.
69
+ useEffect(
70
+ () => () => {
71
+ if (closing.current) {
72
+ clearTimeout(closing.current);
73
+ }
74
+ },
75
+ [],
76
+ );
53
77
 
54
78
  return (
55
- <span
56
- className="relative inline-flex"
57
- onMouseEnter={show}
58
- onMouseLeave={hide}
59
- // Chegando por teclado o rótulo também aparece: quem navega com Tab
60
- // precisa do mesmo texto que o mouse mostra.
61
- onFocus={show}
62
- onBlur={hide}
63
- >
64
- {children}
65
- {label &&
66
- position &&
67
- createPortal(
68
- <span
69
- role="tooltip"
70
- style={{ left: position.x, top: position.y }}
71
- className="pointer-events-none fixed z-100 max-w-[90vw] -translate-x-1/2 whitespace-nowrap rounded-md border border-border bg-popover px-2 py-0.5 text-[11px] font-medium text-popover-foreground shadow-lg"
72
- >
73
- {label}
74
- </span>,
75
- document.body,
76
- )}
77
- </span>
79
+ <Popover open={open && label !== ""} onOpenChange={setOpen}>
80
+ <PopoverAnchor asChild>
81
+ <span
82
+ className="relative inline-flex"
83
+ onMouseEnter={show}
84
+ onMouseLeave={hide}
85
+ onFocus={show}
86
+ onBlur={hide}
87
+ >
88
+ {children}
89
+ </span>
90
+ </PopoverAnchor>
91
+ <PopoverContent
92
+ role="tooltip"
93
+ side="bottom"
94
+ align="center"
95
+ onOpenAutoFocus={(event) => event.preventDefault()}
96
+ onCloseAutoFocus={(event) => event.preventDefault()}
97
+ onMouseEnter={show}
98
+ onMouseLeave={hide}
99
+ className={cn("max-w-xs text-xs leading-relaxed text-muted-foreground", className)}
100
+ >
101
+ {label}
102
+ </PopoverContent>
103
+ </Popover>
78
104
  );
79
105
  };
@@ -5,7 +5,7 @@ import type { ChangeEvent, ClipboardEvent, DragEvent, JSX } from "react";
5
5
  import { useRef, useState } from "react";
6
6
 
7
7
  import { imagesService } from "#core/_services/images";
8
- import { uploadUrlOf } from "#core/_utils/upload-url";
8
+ import { isPendingImagePath, uploadUrlOf } from "#core/_utils/upload-url";
9
9
  import { Button } from "#core/components/ui/button";
10
10
  import { Input } from "#core/components/ui/input";
11
11
  import { Spinner } from "#core/components/ui/spinner";
@@ -102,9 +102,13 @@ export function ImageUpload({
102
102
  return;
103
103
  }
104
104
 
105
- // O arquivo sai do disco junto: caminho que ninguém mais referencia é
106
- // exatamente o que a faxina não varre (a pasta definitiva não é varrida).
107
- await imagesService.remove(profile, value).catch(() => undefined);
105
+ // a pendente sai do disco por aqui é o upload desta sessão que ninguém
106
+ // referencia. A definitiva pertence ao registro: quem a apaga é o salvar
107
+ // (`discard()` no service da feature), e a API recusa o caminho por esta
108
+ // rota de propósito.
109
+ if (isPendingImagePath(value)) {
110
+ await imagesService.remove(profile, value).catch(() => undefined);
111
+ }
108
112
  onChange(null);
109
113
  clearError();
110
114
  };
@@ -21,6 +21,7 @@ export * from "./filter-field";
21
21
  export * from "./filter-group";
22
22
  export * from "./filter-rule";
23
23
  export * from "./filter-sheet";
24
+ export * from "./help-hint";
24
25
  export * from "./hover-tip";
25
26
  export * from "./image-upload";
26
27
  export * from "./input";
@@ -28,6 +29,7 @@ export * from "./job-progress";
28
29
  export * from "./label";
29
30
  export * from "./money-input";
30
31
  export * from "./password-requirements";
32
+ export * from "./period-nav";
31
33
  export * from "./popover";
32
34
  export * from "./row-actions";
33
35
  export * from "./segmented-control";
@@ -0,0 +1,104 @@
1
+ "use client";
2
+
3
+ import { ChevronLeft, ChevronRight } from "lucide-react";
4
+ import type { JSX } from "react";
5
+
6
+ import { Button } from "#core/components/ui/button";
7
+ import { useI18n } from "#core/contexts";
8
+ import { NEXT_ARROW_KEY, PREVIOUS_ARROW_KEY, useShortcut } from "#core/hooks/use-shortcut";
9
+ import { cn } from "#core/lib/utils";
10
+
11
+ export interface PeriodNavProps {
12
+ /** O período em exibição, já formatado — "setembro de 2026", "2026". */
13
+ label: string;
14
+ onPrevious: () => void;
15
+ onNext: () => void;
16
+ /** O nome do gesto, para o leitor de tela: "Mês anterior". Ausente, "Período anterior". */
17
+ previousLabel?: string;
18
+ /** Idem: "Próximo mês". Ausente, "Próximo período". */
19
+ nextLabel?: string;
20
+ /**
21
+ * Classe do rótulo — é onde se fixa a largura.
22
+ *
23
+ * Fixa, e não do tamanho do texto: sem isso "março" e "setembro" empurram
24
+ * o botão da direita para lugares diferentes, e a seta que a pessoa ia
25
+ * clicar sai de baixo do mouse ao trocar o mês.
26
+ */
27
+ labelClassName?: string;
28
+ /** Desliga os dois botões e o atalho, enquanto a tela carrega. */
29
+ disabled?: boolean;
30
+ }
31
+
32
+ /**
33
+ * Anda de período — mês, ano — na tela que tem um só para tudo.
34
+ *
35
+ * Os dois botões e, junto deles, as setas do teclado: `←` volta, `→` avança.
36
+ * Existe como componente pelo mesmo motivo do `CreateButton`: quem põe o
37
+ * seletor ganha o atalho no mesmo gesto, e tela nova nasce com ele em vez de
38
+ * herdá-lo por disciplina.
39
+ *
40
+ * A seta é solta — sem `Alt`, que é o "voltar" do navegador no Windows —, e
41
+ * vale com o foco em qualquer lugar da tela, inclusive numa aba: as abas do
42
+ * core não andam por seta solta justamente para a tecla ter um dono só
43
+ * (`Alt` + seta é o delas). O `useShortcut` ainda cede a vez a menu, lista
44
+ * e slider, que andam por setas dentro de si.
45
+ *
46
+ * Sem dica nos botões: o atalho está na tela de atalhos, e o painel a cada
47
+ * passada do mouse atrapalhava mais do que ensinava.
48
+ *
49
+ * ```tsx
50
+ * <PeriodNav
51
+ * label={formatMonthLong(month)}
52
+ * onPrevious={() => setMonth(shiftMonth(month, -1))}
53
+ * onNext={() => setMonth(shiftMonth(month, 1))}
54
+ * previousLabel={t("finance.month.previous")}
55
+ * nextLabel={t("finance.month.next")}
56
+ * labelClassName="w-40"
57
+ * />
58
+ * ```
59
+ */
60
+ export const PeriodNav = ({
61
+ label,
62
+ onPrevious,
63
+ onNext,
64
+ previousLabel,
65
+ nextLabel,
66
+ labelClassName,
67
+ disabled = false,
68
+ }: PeriodNavProps): JSX.Element => {
69
+ const { t } = useI18n();
70
+ const previousText = previousLabel ?? t("common.previousPeriod");
71
+ const nextText = nextLabel ?? t("common.nextPeriod");
72
+
73
+ useShortcut(PREVIOUS_ARROW_KEY, onPrevious, { enabled: !disabled });
74
+ useShortcut(NEXT_ARROW_KEY, onNext, { enabled: !disabled });
75
+
76
+ return (
77
+ <div className="flex items-center gap-1">
78
+ <Button
79
+ variant="outline"
80
+ size="iconSm"
81
+ aria-label={previousText}
82
+ disabled={disabled}
83
+ onClick={onPrevious}
84
+ >
85
+ <ChevronLeft className="h-4 w-4" />
86
+ </Button>
87
+ <span
88
+ className={cn("text-center text-sm font-medium tabular-nums", labelClassName)}
89
+ aria-live="polite"
90
+ >
91
+ {label}
92
+ </span>
93
+ <Button
94
+ variant="outline"
95
+ size="iconSm"
96
+ aria-label={nextText}
97
+ disabled={disabled}
98
+ onClick={onNext}
99
+ >
100
+ <ChevronRight className="h-4 w-4" />
101
+ </Button>
102
+ </div>
103
+ );
104
+ };
@@ -2,76 +2,50 @@
2
2
 
3
3
  import type { JSX } from "react";
4
4
 
5
- import { MAX_TAB_SHORTCUTS, useShortcut } from "#core/hooks/use-shortcut";
5
+ import {
6
+ NEXT_ARROW_KEY,
7
+ PREVIOUS_ARROW_KEY,
8
+ useShortcut,
9
+ } from "#core/hooks/use-shortcut";
6
10
 
7
11
  export interface TabShortcutsProps {
8
12
  /** Os valores das abas, na ordem em que aparecem. */
9
13
  values: readonly string[];
14
+ /** A aba aberta — é dela que se conta a anterior e a seguinte. */
15
+ value: string;
10
16
  onSelect: (value: string) => void;
11
17
  }
12
18
 
13
19
  /**
14
- * O rótulo do atalho de uma aba, para a dica que a própria aba mostra.
20
+ * `Alt` + `←`/`→` troca de aba. Não desenha nada.
15
21
  *
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.
22
+ * Com `Alt`, e não a seta solta: a seta solta é o atalho de período da tela
23
+ * (`PeriodNav`), e uma tecla com dois donos é o conflito que aparece na
24
+ * tela onde os dois existem. `Alt` + número, que foi o atalho daqui, pedia
25
+ * decorar a posição de cada aba; a seta só pede saber para que lado ir.
29
26
  *
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.
27
+ * a volta: da última, `→` vai para a primeira. É o que o `Ctrl + Tab` do
28
+ * navegador faz, e é o que a mão espera de uma fila.
61
29
  *
62
- * A ordem das abas é a fonte da numeração: passe a mesma lista que desenha os
30
+ * A ordem das abas é a fonte da sequência: passe a mesma lista que desenha os
63
31
  * 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.
32
+ * de a seta pular para a aba errada no dia em que alguém reordenar a barra.
65
33
  */
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
- );
34
+ export const TabShortcuts = ({ values, value, onSelect }: TabShortcutsProps): JSX.Element => {
35
+ const step = (offset: number): void => {
36
+ if (values.length === 0) {
37
+ return;
38
+ }
39
+ // Aba desconhecida (a URL trouxe um valor que já não existe) conta como a
40
+ // primeira: a seta leva a uma aba real em vez de a lugar nenhum.
41
+ const current = Math.max(values.indexOf(value), 0);
42
+ const next = (current + offset + values.length) % values.length;
43
+
44
+ onSelect(values[next]);
45
+ };
46
+
47
+ useShortcut(PREVIOUS_ARROW_KEY, () => step(-1), { alt: true });
48
+ useShortcut(NEXT_ARROW_KEY, () => step(1), { alt: true });
49
+
50
+ return <></>;
51
+ };
@@ -30,13 +30,33 @@ const TabsList = React.forwardRef<
30
30
  ));
31
31
  TabsList.displayName = TabsPrimitive.List.displayName;
32
32
 
33
+ /** As teclas com que o Radix andaria de aba — e que aqui ficam para a tela. */
34
+ const ARROW_KEYS = new Set(["ArrowLeft", "ArrowRight"]);
35
+
36
+ /**
37
+ * A aba do core **não anda por seta**.
38
+ *
39
+ * O Radix trocaria de aba com `←`/`→` enquanto o foco está numa delas — e a
40
+ * aba já tem atalho, `Alt` + número, que funciona de qualquer lugar. Deixar
41
+ * os dois fazia a seta ter dois donos na mesma tela: depois de clicar numa
42
+ * aba, `→` trocava a aba; clicando no fundo, trocava o mês. Impedir o padrão
43
+ * aqui, antes do handler do Radix, devolve a seta ao atalho da tela.
44
+ */
33
45
  const TabsTrigger = React.forwardRef<
34
46
  React.ElementRef<typeof TabsPrimitive.Trigger>,
35
47
  React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
36
- >(({ className, ...props }, ref) => (
48
+ >(({ className, onKeyDown, ...props }, ref) => (
37
49
  <TabsPrimitive.Trigger
38
50
  ref={ref}
39
51
  className={cn(TRACK_ITEM_CLASS, className)}
52
+ // O Radix só trata a tecla se ninguém impediu o padrão antes: o
53
+ // `onKeyDown` de quem usa continua valendo, e o nosso vem em seguida.
54
+ onKeyDown={(event) => {
55
+ onKeyDown?.(event);
56
+ if (ARROW_KEYS.has(event.key)) {
57
+ event.preventDefault();
58
+ }
59
+ }}
40
60
  {...props}
41
61
  />
42
62
  ));
@@ -1,11 +1,12 @@
1
1
  "use client";
2
2
 
3
3
  import {
4
+ ArrowLeftRight,
5
+ CalendarRange,
4
6
  CornerDownLeft,
5
7
  Keyboard,
6
8
  Maximize2,
7
9
  PlusSquare,
8
- Rows3,
9
10
  XSquare,
10
11
  } from "lucide-react";
11
12
  import type { JSX } from "react";
@@ -17,9 +18,12 @@ import {
17
18
  } from "#core/features/shortcuts/components/shortcut-card";
18
19
  import {
19
20
  ALT_KEY_LABELS,
21
+ ARROW_KEY_LABELS,
22
+ ARROW_KEYS_LABEL,
20
23
  CREATE_SHORTCUT_KEYS,
24
+ CTRL_KEY_LABELS,
21
25
  EXPAND_SHORTCUT_KEY,
22
- TAB_SHORTCUT_DIGITS,
26
+ SUBMIT_SHORTCUT_KEY,
23
27
  } from "#core/hooks/use-shortcut";
24
28
 
25
29
  /**
@@ -45,10 +49,10 @@ const SHORTCUTS: Shortcut[] = [
45
49
  name: "shortcuts.tabs.name",
46
50
  // `Alt` e `⌥` são a mesma tecla: viram duas combinações e o card escreve
47
51
  // "ou" entre elas, em vez de a diferença virar nota de rodapé.
48
- combos: ALT_KEY_LABELS.map((alt) => [alt, TAB_SHORTCUT_DIGITS]),
52
+ combos: ALT_KEY_LABELS.map((alt) => [alt, ARROW_KEYS_LABEL]),
49
53
  where: "shortcuts.tabs.where",
50
54
  caveat: "shortcuts.tabs.caveat",
51
- icon: Rows3,
55
+ icon: ArrowLeftRight,
52
56
  },
53
57
  {
54
58
  name: "shortcuts.expand.name",
@@ -57,9 +61,23 @@ const SHORTCUTS: Shortcut[] = [
57
61
  caveat: "shortcuts.expand.caveat",
58
62
  icon: Maximize2,
59
63
  },
64
+ {
65
+ name: "shortcuts.period.name",
66
+ // Uma seta para cada lado: são dois atalhos, e o card escreve "ou" entre
67
+ // eles como faz com as duas grafias do Alt.
68
+ combos: ARROW_KEY_LABELS.map((key) => [key]),
69
+ where: "shortcuts.period.where",
70
+ caveat: "shortcuts.period.caveat",
71
+ icon: CalendarRange,
72
+ },
60
73
  {
61
74
  name: "shortcuts.submit.name",
62
- combos: [["Enter"]],
75
+ // O Enter sozinho é o submit do HTML; com Ctrl (⌘ no Mac) é o nosso, que
76
+ // vale de qualquer campo do modal.
77
+ combos: [
78
+ [SUBMIT_SHORTCUT_KEY],
79
+ ...CTRL_KEY_LABELS.map((ctrl) => [ctrl, SUBMIT_SHORTCUT_KEY]),
80
+ ],
63
81
  where: "shortcuts.submit.where",
64
82
  caveat: "shortcuts.submit.caveat",
65
83
  icon: CornerDownLeft,
@@ -8,6 +8,49 @@ const DOUBLE_PRESS_WINDOW_MS = 500;
8
8
  /** Onde a tecla é texto, e não comando. */
9
9
  const TYPING_TAGS = new Set(["INPUT", "TEXTAREA", "SELECT"]);
10
10
 
11
+ /** As teclas que andam dentro de um widget — e que ele espera receber. */
12
+ const NAVIGATION_KEYS = new Set([
13
+ "ArrowLeft",
14
+ "ArrowRight",
15
+ "ArrowUp",
16
+ "ArrowDown",
17
+ "Home",
18
+ "End",
19
+ ]);
20
+
21
+ /**
22
+ * Os widgets que usam as setas para andar por dentro de si.
23
+ *
24
+ * Menus, listas de opções, rádios, sliders e grades seguem o padrão ARIA de
25
+ * foco itinerante: a seta troca o item focado. Com o foco num deles, a tecla
26
+ * é do widget — um atalho global de seta que também disparasse andaria o
27
+ * menu **e** o período no mesmo aperto.
28
+ *
29
+ * A aba **não** está aqui de propósito: o `Tabs` do core desliga a seta
30
+ * solta (a aba anda com `Alt` + seta), para `←`/`→` ter um dono só na tela.
31
+ *
32
+ * Por `closest`, e não pelo alvo: a opção do select é um item dentro do
33
+ * `listbox`, e a célula é um botão dentro do `grid`.
34
+ */
35
+ const NAVIGATION_WIDGET_SELECTOR = [
36
+ "menu",
37
+ "menubar",
38
+ "menuitem",
39
+ "menuitemcheckbox",
40
+ "menuitemradio",
41
+ "listbox",
42
+ "option",
43
+ "radiogroup",
44
+ "radio",
45
+ "slider",
46
+ "spinbutton",
47
+ "grid",
48
+ "tree",
49
+ "treeitem",
50
+ ]
51
+ .map((role) => `[role="${role}"]`)
52
+ .join(", ");
53
+
11
54
  /**
12
55
  * As teclas que abrem o formulário de cadastro, em toda tela que tiver um.
13
56
  *
@@ -47,6 +90,26 @@ export const EXPAND_SHORTCUT_KEY = "Enter";
47
90
  /** Como ele se apresenta ao usuário. */
48
91
  export const EXPAND_SHORTCUT_LABEL = "Alt + Enter";
49
92
 
93
+ /**
94
+ * A tecla que envia o formulário de qualquer lugar do modal, com `Ctrl`.
95
+ *
96
+ * O `Enter` sozinho já envia — é o submit implícito do HTML —, mas só de um
97
+ * campo de texto: num select ele abre a lista, numa caixa de marcar ele marca,
98
+ * num texto longo ele quebra a linha. `Ctrl + Enter` (`⌘ + Enter` no Mac) é
99
+ * a convenção de "enviar mesmo assim" que chat e editor já ensinaram, e não
100
+ * escreve nada em lugar nenhum.
101
+ */
102
+ export const SUBMIT_SHORTCUT_KEY = "Enter";
103
+
104
+ /**
105
+ * O modificador do `Ctrl`, escrito das duas formas que os teclados usam.
106
+ *
107
+ * `Ctrl` e `⌘` **não** são a mesma tecla física, mas são a mesma na cabeça
108
+ * de quem usa — e o hook aceita as duas por `ctrl: true`. A tela de atalhos
109
+ * mostra as duas pelo mesmo motivo do `Alt`/`⌥`.
110
+ */
111
+ export const CTRL_KEY_LABELS = ["Ctrl", "⌘"] as const;
112
+
50
113
  /**
51
114
  * O modificador do `Alt`, escrito das duas formas que os teclados usam.
52
115
  *
@@ -57,15 +120,24 @@ export const EXPAND_SHORTCUT_LABEL = "Alt + Enter";
57
120
  export const ALT_KEY_LABELS = ["Alt", "⌥"] as const;
58
121
 
59
122
  /**
60
- * Quantas abas o atalho numera.
123
+ * As setas as duas teclas de "anterior" e "seguinte" do sistema.
124
+ *
125
+ * Soltas, andam de período (mês, ano) na tela que tem um seletor; com `Alt`,
126
+ * andam de aba. A tecla é a mesma e o modificador diz o quê: a seta solta não
127
+ * escreve nada e não é atalho de navegador, e o que ela faz por conta própria
128
+ * — andar dentro de menus e listas — o hook respeita cedendo a vez.
61
129
  *
62
- * Nove porque `Alt + 0` não segue a sequência, e porque ninguém conta abas além
63
- * disso sem olhar.
130
+ * `Alt + ←` é o "voltar" do navegador no Windows; o hook impede o padrão
131
+ * quando o atalho dispara, e fora de uma tela com abas a tecla segue livre.
64
132
  */
65
- export const MAX_TAB_SHORTCUTS = 9;
133
+ export const PREVIOUS_ARROW_KEY = "ArrowLeft";
134
+ export const NEXT_ARROW_KEY = "ArrowRight";
66
135
 
67
- /** Como o atalho de abas se apresenta: `Alt` + `1…9`. */
68
- export const TAB_SHORTCUT_DIGITS = `1…${MAX_TAB_SHORTCUTS}`;
136
+ /** Como as setas se apresentam, uma para cada lado. */
137
+ export const ARROW_KEY_LABELS = ["←", "→"] as const;
138
+
139
+ /** As duas setas num rótulo só, para o atalho que usa qualquer uma delas. */
140
+ export const ARROW_KEYS_LABEL = "← →";
69
141
 
70
142
  export interface ShortcutOptions {
71
143
  /**
@@ -162,6 +234,9 @@ export interface ShortcutOptions {
162
234
  * ele — senão o atalho abriria um segundo por cima do primeiro.
163
235
  * - **Modificador não pedido cancela.** `Ctrl`/`Cmd`/`Alt` + a tecla costuma ser
164
236
  * atalho do navegador ou do sistema; quem quiser um deles declara `ctrl`.
237
+ * - **Seta com o foco num widget que anda por setas é dele.** Menu, lista,
238
+ * rádio, slider e grade trocam o item focado com `←`/`→`; o atalho global
239
+ * de seta não dispara ali.
165
240
  *
166
241
  * ```tsx
167
242
  * // Na listagem: duas batidas abrem o cadastro.
@@ -244,6 +319,9 @@ export function useShortcut(
244
319
  if (typing && !allowWhileTyping) {
245
320
  return;
246
321
  }
322
+ if (isNavigatingWidget(event)) {
323
+ return;
324
+ }
247
325
  if (!allowInDialog && hasOpenDialog()) {
248
326
  return;
249
327
  }
@@ -304,6 +382,20 @@ const isTyping = (target: EventTarget | null): boolean => {
304
382
  return TYPING_TAGS.has(target.tagName) || target.isContentEditable;
305
383
  };
306
384
 
385
+ /**
386
+ * A tecla é de navegação e o foco está num widget que a consome?
387
+ *
388
+ * Checado aqui, e não por `event.defaultPrevented`, porque o hook ouve na
389
+ * fase de captura: quando ele vê a tecla, o widget ainda não a tratou.
390
+ */
391
+ const isNavigatingWidget = (event: KeyboardEvent): boolean => {
392
+ if (!NAVIGATION_KEYS.has(event.key) || !(event.target instanceof HTMLElement)) {
393
+ return false;
394
+ }
395
+
396
+ return event.target.closest(NAVIGATION_WIDGET_SELECTOR) !== null;
397
+ };
398
+
307
399
  /**
308
400
  * Há um modal aberto?
309
401
  *
@@ -145,7 +145,7 @@ export const en: Messages = {
145
145
  tabs: {
146
146
  name: "Switch tab",
147
147
  where:
148
- "On any screen with tabs, the number opens the tab in that position1 is the first.",
148
+ "On any screen with tabs: the left arrow goes to the previous tab, the right one to the next and from the last it wraps to the first.",
149
149
  caveat: "Does not fire inside a text field or with a modal open",
150
150
  },
151
151
  expand: {
@@ -156,16 +156,24 @@ export const en: Messages = {
156
156
  },
157
157
  submit: {
158
158
  name: "Submit the form",
159
- where: "With a create dialog open, saves without clicking.",
160
- caveat: "Inside a long text field, Enter adds a line break",
159
+ where:
160
+ "With a create dialog open, saves without clicking. Enter alone submits from a text field; with Ctrl it submits from anywhere in the dialog — a select, a checkbox or a long text, where Enter alone opens, toggles or adds a line break.",
161
+ caveat: "With the Save button disabled, neither one submits",
161
162
  },
162
163
  close: {
163
164
  name: "Close the dialog",
164
165
  where: "Closes the open dialog, same as the close button.",
165
166
  caveat: "A changed form asks for confirmation before leaving",
166
167
  },
168
+ period: {
169
+ name: "Change the period",
170
+ where:
171
+ "On a screen with a month or year selector at the top: the left arrow goes back one period, the right one moves forward.",
172
+ caveat:
173
+ "Does not fire inside a text field, with a dialog open, or with focus on a tab, list or calendar — there the arrow is theirs",
174
+ },
167
175
  footer:
168
- "Two keys open the create form because keyboards differ: the backslash moves around depending on the layout, while the number is always in the same place.",
176
+ "Two keys open the create form because keyboards differ: the backslash moves around depending on the layout, while the number is always in the same place. The arrows do two things and Alt tells which: alone they change the period, with Alt they switch tabs.",
169
177
  },
170
178
  export: {
171
179
  label: "Export",
@@ -306,6 +314,8 @@ export const en: Messages = {
306
314
  no: "No",
307
315
  actions: "Actions",
308
316
  shortcutHint: "Shortcut: {keys}",
317
+ previousPeriod: "Previous period",
318
+ nextPeriod: "Next period",
309
319
  loading: "Loading...",
310
320
  description: "Description",
311
321
  language: "Language",
@@ -147,7 +147,7 @@ export const pt = {
147
147
  tabs: {
148
148
  name: "Trocar de aba",
149
149
  where:
150
- "Em qualquer tela com abas, o número abre a aba naquela posição1 é a primeira.",
150
+ "Em qualquer tela com abas: a seta para a esquerda vai para a aba anterior, a da direita para a seguinte e da última volta para a primeira.",
151
151
  caveat: "Não dispara dentro de um campo de texto nem com um modal aberto",
152
152
  },
153
153
  expand: {
@@ -158,16 +158,24 @@ export const pt = {
158
158
  },
159
159
  submit: {
160
160
  name: "Enviar o formulário",
161
- where: "Com um modal de cadastro aberto, salva sem precisar clicar.",
162
- caveat: "Dentro de um campo de texto longo, o Enter quebra a linha",
161
+ where:
162
+ "Com um modal de cadastro aberto, salva sem precisar clicar. O Enter sozinho envia a partir de um campo de texto; com Ctrl envia de qualquer lugar do modal — de um select, de uma caixa de marcar ou de um texto longo, onde o Enter sozinho abre, marca ou quebra a linha.",
163
+ caveat: "Com o botão Salvar desativado, nenhum dos dois envia",
163
164
  },
164
165
  close: {
165
166
  name: "Fechar o modal",
166
167
  where: "Fecha o modal aberto, como o botão de fechar.",
167
168
  caveat: "Um formulário alterado pede confirmação antes de sair",
168
169
  },
170
+ period: {
171
+ name: "Mudar o período",
172
+ where:
173
+ "Na tela que tem um seletor de mês ou ano no topo: a seta para a esquerda volta um período, a da direita avança.",
174
+ caveat:
175
+ "Não dispara dentro de um campo de texto, com um modal aberto, nem com o foco numa aba, lista ou calendário — ali a seta é deles",
176
+ },
169
177
  footer:
170
- "Duas teclas para abrir o cadastro porque o teclado não é um só: a barra invertida muda de lugar conforme o layout, e o número está sempre na mesma posição. Já o atalho de ampliar leva Alt porque precisa valer dentro dos campos — e uma tecla que escreve não pode servir de comando ali.",
178
+ "Duas teclas para abrir o cadastro porque o teclado não é um só: a barra invertida muda de lugar conforme o layout, e o número está sempre na mesma posição. As setas fazem duas coisas e o Alt diz qual: soltas andam de período, com Alt andam de aba. Já o atalho de ampliar leva Alt porque precisa valer dentro dos campos — e uma tecla que escreve não pode servir de comando ali.",
171
179
  },
172
180
  export: {
173
181
  label: "Exportar",
@@ -309,6 +317,8 @@ export const pt = {
309
317
  no: "Não",
310
318
  actions: "Ações",
311
319
  shortcutHint: "Atalho: {keys}",
320
+ previousPeriod: "Período anterior",
321
+ nextPeriod: "Próximo período",
312
322
  loading: "Carregando...",
313
323
  description: "Descrição",
314
324
  language: "Idioma",
package/src/index.ts CHANGED
@@ -113,12 +113,16 @@ export type { RunOptions, UseRequestResult } from "#core/hooks/use-request";
113
113
  export type { ShortcutOptions } from "#core/hooks/use-shortcut";
114
114
  export {
115
115
  ALT_KEY_LABELS,
116
+ ARROW_KEY_LABELS,
117
+ ARROW_KEYS_LABEL,
116
118
  CREATE_SHORTCUT_KEYS,
117
119
  CREATE_SHORTCUT_LABEL,
120
+ CTRL_KEY_LABELS,
118
121
  EXPAND_SHORTCUT_KEY,
119
122
  EXPAND_SHORTCUT_LABEL,
120
- MAX_TAB_SHORTCUTS,
121
- TAB_SHORTCUT_DIGITS,
123
+ NEXT_ARROW_KEY,
124
+ PREVIOUS_ARROW_KEY,
125
+ SUBMIT_SHORTCUT_KEY,
122
126
  useShortcut,
123
127
  } from "#core/hooks/use-shortcut";
124
128
  export type { UseTabStateOptions, UseTabStateResult } from "#core/hooks/use-tab-state";
package/src/middleware.ts CHANGED
@@ -7,9 +7,119 @@ import { NextRequest, NextResponse } from "next/server";
7
7
  *
8
8
  * Observação: a validação forte do token é feita no backend; aqui é só
9
9
  * o "portão" de roteamento (UX), evitando flicker de telas privadas.
10
+ *
11
+ * Também é aqui que a Content-Security-Policy nasce, porque o nonce precisa
12
+ * ser novo a cada requisição e chegar ao `layout.tsx` antes de a página
13
+ * renderizar — ver `buildContentSecurityPolicy`.
10
14
  */
11
15
  const PUBLIC_PATHS = ["/login", "/forgot-password", "/reset-password"];
12
16
 
17
+ /** Cabeçalho por onde o nonce chega ao `layout.tsx` (`headers().get("x-nonce")`). */
18
+ export const NONCE_HEADER = "x-nonce";
19
+
20
+ export interface ContentSecurityPolicyOptions {
21
+ /** Nonce desta requisição — vai nos scripts inline do Next e no de tema do layout. */
22
+ nonce: string;
23
+ /** Origens extras de `connect-src` (outra API, um serviço de terceiros). */
24
+ extraConnectSrc?: string[];
25
+ /** Origens extras de `img-src` (CDN de imagens, S3). */
26
+ extraImgSrc?: string[];
27
+ }
28
+
29
+ /**
30
+ * Os mesmos padrões do `axios.factory` e do `socket-context`: sem a variável, a
31
+ * CSP precisa liberar exatamente para onde o cliente vai cair, senão o navegador
32
+ * bloqueia toda chamada.
33
+ */
34
+ const DEFAULT_API_BASE_URL = "http://localhost:3108/api/v1";
35
+ const DEFAULT_SOCKET_URL = "http://localhost:3108";
36
+
37
+ const originOf = (url: string): string | null => {
38
+ try {
39
+ return new URL(url).origin;
40
+ } catch {
41
+ return null;
42
+ }
43
+ };
44
+
45
+ /** Origem da API (`NEXT_PUBLIC_API_BASE_URL`), sem prefixo — é de lá que vêm avatar e download. */
46
+ const apiOrigin = (): string | null =>
47
+ originOf(process.env.NEXT_PUBLIC_API_BASE_URL ?? DEFAULT_API_BASE_URL);
48
+
49
+ /** Origem do socket (`NEXT_PUBLIC_SOCKET_URL`) — costuma ser a da API, mas pode divergir. */
50
+ const socketOrigin = (): string | null =>
51
+ originOf(process.env.NEXT_PUBLIC_SOCKET_URL ?? DEFAULT_SOCKET_URL);
52
+
53
+ /**
54
+ * A CSP do core.
55
+ *
56
+ * Defesa em profundidade: hoje não há sink de XSS na interface, e a CSP é o
57
+ * que limita o estrago de um que apareça num componente de terceiro. Script só
58
+ * com nonce (`strict-dynamic` deixa o Next carregar os chunks dele); `img` e
59
+ * `connect` liberam a origem da API, porque avatar, axios e socket.io falam
60
+ * com ela. `style` aceita inline porque Tailwind e Radix escrevem `style=`
61
+ * direto no elemento — bloquear isso quebraria todo popover e menu.
62
+ *
63
+ * `unsafe-eval` só em desenvolvimento: o React o usa para reconstruir stack
64
+ * de erro do servidor no navegador; em produção nem React nem Next o usam.
65
+ *
66
+ * Exportada para o projeto que precisar de mais origens montar a própria: chama
67
+ * esta com `extraConnectSrc`/`extraImgSrc` dentro do middleware dele.
68
+ */
69
+ export const buildContentSecurityPolicy = ({
70
+ nonce,
71
+ extraConnectSrc = [],
72
+ extraImgSrc = [],
73
+ }: ContentSecurityPolicyOptions): string => {
74
+ const api = apiOrigin();
75
+ const socket = socketOrigin();
76
+ const isDev = process.env.NODE_ENV === "development";
77
+ // O socket.io começa por polling HTTP e sobe para WebSocket: as duas formas.
78
+ const connect = [
79
+ "'self'",
80
+ api,
81
+ socket,
82
+ socket?.replace(/^http/, "ws") ?? null,
83
+ ...extraConnectSrc,
84
+ ].filter(Boolean);
85
+ const img = ["'self'", "blob:", "data:", api, ...extraImgSrc].filter(Boolean);
86
+
87
+ return [
88
+ "default-src 'self'",
89
+ `script-src 'self' 'nonce-${nonce}' 'strict-dynamic'${isDev ? " 'unsafe-eval'" : ""}`,
90
+ "style-src 'self' 'unsafe-inline'",
91
+ `img-src ${img.join(" ")}`,
92
+ "font-src 'self' data:",
93
+ `connect-src ${connect.join(" ")}`,
94
+ "object-src 'none'",
95
+ "base-uri 'self'",
96
+ "form-action 'self'",
97
+ "frame-ancestors 'none'",
98
+ ].join("; ");
99
+ };
100
+
101
+ /**
102
+ * Devolve a resposta "segue em frente" já com o nonce e a CSP nos dois lados:
103
+ * no request (é de onde o Next lê o nonce para os scripts dele) e no response.
104
+ */
105
+ export const nextWithSecurityHeaders = (
106
+ req: NextRequest,
107
+ options: Omit<ContentSecurityPolicyOptions, "nonce"> = {},
108
+ ): NextResponse => {
109
+ const nonce = Buffer.from(crypto.randomUUID()).toString("base64");
110
+ const csp = buildContentSecurityPolicy({ nonce, ...options });
111
+
112
+ const requestHeaders = new Headers(req.headers);
113
+ requestHeaders.set(NONCE_HEADER, nonce);
114
+ requestHeaders.set("Content-Security-Policy", csp);
115
+
116
+ const response = NextResponse.next({ request: { headers: requestHeaders } });
117
+ response.headers.set("Content-Security-Policy", csp);
118
+ response.headers.set("X-Content-Type-Options", "nosniff");
119
+ response.headers.set("Referrer-Policy", "strict-origin-when-cross-origin");
120
+ return response;
121
+ };
122
+
13
123
  export function middleware(req: NextRequest): NextResponse<unknown> {
14
124
  const { pathname } = req.nextUrl;
15
125
  const hasSession =
@@ -34,7 +144,7 @@ export function middleware(req: NextRequest): NextResponse<unknown> {
34
144
  return NextResponse.redirect(url);
35
145
  }
36
146
 
37
- return NextResponse.next();
147
+ return nextWithSecurityHeaders(req);
38
148
  }
39
149
 
40
150
  export const config = {