rl-core-front 0.19.0 → 0.19.2

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.0",
3
+ "version": "0.19.2",
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",
@@ -8,9 +8,12 @@
8
8
  * enquanto este parser recusa o que não entende em vez de tentar adivinhar.
9
9
  *
10
10
  * O que ele entende: `+ - * / %`, parênteses, sinal negativo, e número no
11
- * formato brasileiro (ponto de milhar, vírgula decimal).
11
+ * formato brasileiro (ponto de milhar, vírgula decimal) — com os dígitos
12
+ * soltos lidos em centavos, como o campo de dinheiro faz (`4250` é 42,50).
12
13
  */
13
14
 
15
+ import { maskCurrencyBR } from "#core/_utils/format";
16
+
14
17
  /**
15
18
  * Por que a conta não fechou. É código e não frase pronta porque a mensagem é
16
19
  * texto de tela: quem traduz é o componente, com o dicionário de quem está
@@ -83,15 +86,99 @@ const SYMBOL_TO_GLYPH = new Map<string, string>(OPERATOR_GLYPHS);
83
86
 
84
87
  const SYMBOLS = "+-*/%()";
85
88
 
89
+ /** O desenho de uma tecla: `*` vira `×`; o resto fica como é. */
90
+ export const glyphOf = (symbol: string): string => SYMBOL_TO_GLYPH.get(symbol) ?? symbol;
91
+
92
+ /** Um pedaço da conta: um número (e se ele é dinheiro ou não) ou um símbolo. */
93
+ interface Run {
94
+ kind: "number" | "symbol";
95
+ text: string;
96
+ /** Só em número: quantidade ou porcentagem, e não dinheiro — ver `toNumber`. */
97
+ plain: boolean;
98
+ }
99
+
86
100
  /**
87
- * A conta como se na tela: `120*2+10%` vira `120×2+10%`.
101
+ * A conta em pedaços, com cada número sabendo se é dinheiro.
88
102
  *
89
- * Mora aqui, junto do que faz o caminho de volta, e não no componente: são a
90
- * mesma correspondência, e separadas elas saem de sincronia calada.
103
+ * É a mesma pergunta que o tokenizador faz o que vem depois de `×`/`÷` e
104
+ * o que vem antes de `%` não é dinheiro —, respondida sobre o texto cru para
105
+ * o visor e a normalização usarem sem avaliar a conta (que pode estar pela
106
+ * metade). O que não é número nem símbolo é descartado.
107
+ */
108
+ const splitRuns = (source: string): Run[] => {
109
+ const runs: Run[] = [];
110
+ let index = 0;
111
+
112
+ while (index < source.length) {
113
+ const character: string = source[index];
114
+
115
+ if (NUMBER_PART.test(character)) {
116
+ let end: number = index;
117
+ while (end < source.length && NUMBER_PART.test(source[end])) {
118
+ end += 1;
119
+ }
120
+ runs.push({ kind: "number", text: source.slice(index, end), plain: false });
121
+ index = end;
122
+ continue;
123
+ }
124
+
125
+ const symbol: string = GLYPH_TO_SYMBOL.get(character) ?? character;
126
+ if (SYMBOLS.includes(symbol)) {
127
+ runs.push({ kind: "symbol", text: symbol, plain: false });
128
+ }
129
+ index += 1;
130
+ }
131
+
132
+ runs.forEach((run, position) => {
133
+ if (run.kind !== "number") {
134
+ return;
135
+ }
136
+ const previous: Run | undefined = runs[position - 1];
137
+ const next: Run | undefined = runs[position + 1];
138
+
139
+ run.plain =
140
+ (previous?.kind === "symbol" && (previous.text === "*" || previous.text === "/")) ||
141
+ (next?.kind === "symbol" && next.text === "%");
142
+ });
143
+
144
+ return runs;
145
+ };
146
+
147
+ /**
148
+ * O texto do visor, como a pessoa o vê e edita, na forma que a conta lê.
149
+ *
150
+ * O visor é um campo de texto que mostra dinheiro formatado (`44,90`), e a
151
+ * pessoa digita, apaga e cola nele. O que sai daqui é a forma canônica: os
152
+ * símbolos em vez dos desenhos, o dinheiro só em dígitos (`4490` — a vírgula
153
+ * e o ponto são da máscara, e o que a pessoa digitou de ponto ou vírgula num
154
+ * valor em reais é descartado, como o campo faz), e a quantidade e a
155
+ * porcentagem como vieram, que ali `1,5` é literal.
156
+ */
157
+ export const normalizeExpression = (display: string): string =>
158
+ splitRuns(display)
159
+ .map((run) =>
160
+ run.kind === "number" && !run.plain ? run.text.replace(/\D/g, "") : run.text,
161
+ )
162
+ .join("");
163
+
164
+ /**
165
+ * A conta como se lê na tela: `4490*2+10%` vira `44,90×2+10%`.
166
+ *
167
+ * O dinheiro sai formatado como o campo o mostra — `4490` é 44,90 —, para
168
+ * a pessoa ver o mesmo que a conta vai ler; a quantidade e a porcentagem
169
+ * ficam como estão. Mora aqui, junto do que faz o caminho de volta
170
+ * (`normalizeExpression`), e não no componente: são a mesma correspondência,
171
+ * e separadas elas saem de sincronia calada.
91
172
  */
92
173
  export const toDisplayExpression = (source: string): string =>
93
- Array.from(source)
94
- .map((character) => SYMBOL_TO_GLYPH.get(character) ?? character)
174
+ splitRuns(source)
175
+ .map((run) => {
176
+ if (run.kind === "symbol") {
177
+ return SYMBOL_TO_GLYPH.get(run.text) ?? run.text;
178
+ }
179
+
180
+ return run.plain ? run.text : maskCurrencyBR(run.text);
181
+ })
95
182
  .join("");
96
183
 
97
184
  /**
@@ -109,14 +196,25 @@ export const isCalcCharacter = (character: string): boolean =>
109
196
  /**
110
197
  * Número brasileiro para `number`.
111
198
  *
112
- * A ambiguidade real é o ponto sozinho: `1.200` é mil e duzentos para quem
113
- * digita em reais, e 1,2 para o `Number`. A regra é a do país havendo
114
- * vírgula, todo ponto é milhar; sem vírgula, o ponto ainda é milhar quando
115
- * separa exatamente três dígitos no fim, ou quando aparece mais de uma vez.
199
+ * **Dígitos soltos são centavos**, como no campo: quem digita `4250` no campo
200
+ * 42,50, e a calculadora lendo 4.250,00 era a mesma tecla dando dois
201
+ * valores. A vírgula, quando vem, manda `42,50` é literal. Duas exceções,
202
+ * que são o que não é dinheiro (`plain`): a porcentagem (`10%` são dez por
203
+ * cento, não 0,10%) e o que vem depois de `×` ou `÷` — quantidade, não
204
+ * valor: `4250×3` são três de 42,50, e `100,00÷4` é dividir em quatro.
205
+ *
206
+ * O ponto sozinho é milhar: `1.200` é mil e duzentos para quem digita em
207
+ * reais, e 1,2 para o `Number`. A regra é a do país — havendo vírgula, todo
208
+ * ponto é milhar; sem vírgula, o ponto ainda é milhar quando separa exatamente
209
+ * três dígitos no fim, ou quando aparece mais de uma vez.
116
210
  */
117
- const toNumber = (raw: string): number => {
211
+ const toNumber = (raw: string, plain: boolean): number => {
118
212
  let text: string = raw;
119
213
 
214
+ if (/^\d+$/.test(text) && !plain) {
215
+ return Number(text) / 100;
216
+ }
217
+
120
218
  if (text.includes(",")) {
121
219
  text = text.replace(/\./g, "").replace(/,/g, ".");
122
220
  } else if ((text.match(/\./g) ?? []).length > 1 || /\d\.\d{3}$/.test(text)) {
@@ -148,7 +246,13 @@ const tokenize = (source: string): Token[] => {
148
246
  while (end < source.length && NUMBER_PART.test(source[end])) {
149
247
  end += 1;
150
248
  }
151
- tokens.push({ type: "number", value: toNumber(source.slice(index, end)) });
249
+ // O `%` logo depois, ou o `×`/`÷` logo antes, mudam a leitura do
250
+ // número — ver `toNumber`.
251
+ const previous: Token | undefined = tokens[tokens.length - 1];
252
+ const plain: boolean =
253
+ /^\s*%/.test(source.slice(end)) ||
254
+ (previous?.type === "*" || previous?.type === "/");
255
+ tokens.push({ type: "number", value: toNumber(source.slice(index, end), plain) });
152
256
  index = end;
153
257
  continue;
154
258
  }
@@ -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
  };
@@ -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";
@@ -1,14 +1,16 @@
1
1
  "use client";
2
2
 
3
3
  import { Calculator, Delete } from "lucide-react";
4
- import type { JSX, KeyboardEvent } from "react";
4
+ import type { ChangeEvent, JSX, KeyboardEvent } from "react";
5
5
  import { useRef, useState } from "react";
6
6
 
7
7
  import {
8
8
  CalcError,
9
9
  CalcErrorCode,
10
10
  evaluateExpression,
11
+ glyphOf,
11
12
  isCalcCharacter,
13
+ normalizeExpression,
12
14
  toDisplayExpression,
13
15
  } from "#core/_utils/calc";
14
16
  import {
@@ -61,7 +63,7 @@ type KeyRole = "digit" | "operator" | "aux";
61
63
  interface CalcKey {
62
64
  /**
63
65
  * O que a tecla acrescenta à conta — e também o seu desenho, passado pelo
64
- * `toDisplayExpression`: a tecla do `*` mostra `×` sem que o glifo precise
66
+ * `toGlyphs`: a tecla do `*` mostra `×` sem que o glifo precise
65
67
  * ser escrito aqui de novo.
66
68
  */
67
69
  input?: string;
@@ -126,8 +128,15 @@ export const MoneyInput = ({
126
128
  }: MoneyInputProps): JSX.Element => {
127
129
  const { t } = useI18n();
128
130
  const [open, setOpen] = useState(false);
131
+ /*
132
+ A conta na forma canônica — símbolos, dinheiro só em dígitos (`4490`). O
133
+ visor mostra e edita a forma desenhada (`44,90`), e cada mudança volta
134
+ para cá por `normalizeExpression`: é o que deixa a pessoa digitar, apagar
135
+ e colar num campo formatado sem a máscara e o cursor brigarem.
136
+ */
129
137
  const [expression, setExpression] = useState("");
130
- const panelRef = useRef<HTMLDivElement>(null);
138
+ const visorRef = useRef<HTMLInputElement>(null);
139
+ const display: string = toDisplayExpression(expression);
131
140
 
132
141
  /**
133
142
  * Traduz o erro do parser. O código vem do `calc.util`, que não conhece
@@ -165,24 +174,115 @@ export const MoneyInput = ({
165
174
  }
166
175
  })();
167
176
 
168
- /** Abrir semeia a conta com o que já está no campo, para somar em cima. */
177
+ /**
178
+ * Abrir semeia a conta com o que já está no campo, para somar em cima.
179
+ *
180
+ * Em dígitos (`359`), e não no texto do campo (`3,59`): com a vírgula o
181
+ * número vira literal, e cada dígito digitado depois entrava como casa
182
+ * decimal a mais — `3,5922` arredondava de volta para 3,59, e o valor
183
+ * parecia travado. Em dígitos o visor mostra o mesmo 3,59, e digitar em
184
+ * cima empurra o valor como o campo faz.
185
+ */
169
186
  const handleOpenChange = (next: boolean): void => {
170
187
  if (next) {
171
- setExpression(parseCurrencyBR(value) > 0 ? value : "");
188
+ const cents: number = Math.round(parseCurrencyBR(value) * 100);
189
+ setExpression(cents > 0 ? String(cents) : "");
172
190
  }
173
191
  setOpen(next);
174
192
  };
175
193
 
194
+ /**
195
+ * O visor como ficou depois de uma edição, e onde o cursor estava nele.
196
+ *
197
+ * O texto novo é normalizado e desenhado de novo — a máscara pode ter
198
+ * mudado de lugar (`449` é 4,49; `4490` é 44,90) —, e o cursor é posto
199
+ * com o mesmo número de caracteres "de verdade" (tudo que não é ponto,
200
+ * vírgula ou espaço) **à direita** dele. É o que faz digitar no meio de
201
+ * `44,90` cair onde a pessoa clicou, e não no fim.
202
+ *
203
+ * Contado do fim, e não do início, por causa do zero que a máscara põe na
204
+ * frente: `9` desenha `0,09`, e contando do início o cursor caía depois
205
+ * desse zero — o `8` seguinte entrava antes da vírgula, e `987` virava
206
+ * 80,79 em vez de 9,87.
207
+ */
208
+ const commit = (nextDisplay: string, caret: number): void => {
209
+ const significantAfter: number = Array.from(nextDisplay.slice(caret)).filter(
210
+ (character) => !/[.,\s]/.test(character),
211
+ ).length;
212
+ const raw: string = normalizeExpression(nextDisplay);
213
+ const redrawn: string = toDisplayExpression(raw);
214
+
215
+ let position: number = redrawn.length;
216
+ let seen = 0;
217
+ while (position > 0 && seen < significantAfter) {
218
+ if (!/[.,\s]/.test(redrawn[position - 1])) {
219
+ seen += 1;
220
+ }
221
+ position -= 1;
222
+ }
223
+
224
+ setExpression(raw);
225
+ // Depois do render, quando o campo já mostra o texto redesenhado.
226
+ requestAnimationFrame(() => {
227
+ visorRef.current?.focus();
228
+ visorRef.current?.setSelectionRange(position, position);
229
+ });
230
+ };
231
+
232
+ /** Onde a seleção está no visor — no fim, se o campo ainda não tem foco. */
233
+ const selection = (): [number, number] => [
234
+ visorRef.current?.selectionStart ?? display.length,
235
+ visorRef.current?.selectionEnd ?? display.length,
236
+ ];
237
+
238
+ /**
239
+ * A tecla escreve onde o cursor está — e por cima do que estiver
240
+ * selecionado, como qualquer campo de texto.
241
+ *
242
+ * O visor foi um texto fixo em que as teclas só acrescentavam no fim: para
243
+ * trocar o `500` do meio da conta era apagar até lá e digitar tudo de novo,
244
+ * e selecionar tudo e digitar acrescentava em vez de substituir.
245
+ */
246
+ const write = (text: string): void => {
247
+ const [start, end] = selection();
248
+
249
+ commit(display.slice(0, start) + text + display.slice(end), start + text.length);
250
+ };
251
+
252
+ /**
253
+ * O backspace da tecla: apaga a seleção, ou o caractere antes do cursor —
254
+ * pulando o ponto e a vírgula, que são da máscara e voltam sozinhos.
255
+ */
256
+ const erase = (): void => {
257
+ const [start, end] = selection();
258
+ let from: number = start;
259
+
260
+ if (start === end) {
261
+ while (from > 0 && /[.,]/.test(display[from - 1])) {
262
+ from -= 1;
263
+ }
264
+ from = Math.max(from - 1, 0);
265
+ }
266
+
267
+ commit(display.slice(0, from) + display.slice(end), from);
268
+ };
269
+
176
270
  const press = (key: CalcKey): void => {
177
271
  if (key.action === "clear") {
178
272
  setExpression("");
273
+ visorRef.current?.focus();
179
274
  return;
180
275
  }
181
276
  if (key.action === "backspace") {
182
- setExpression((current) => current.slice(0, -1));
277
+ erase();
183
278
  return;
184
279
  }
185
- setExpression((current) => current + (key.input ?? ""));
280
+ write(key.input ?? "");
281
+ };
282
+
283
+ /** O que foi digitado, apagado ou colado direto no visor. */
284
+ const handleVisorChange = (event: ChangeEvent<HTMLInputElement>): void => {
285
+ commit(event.target.value, event.target.selectionStart ?? event.target.value.length);
186
286
  };
187
287
 
188
288
  const apply = (): void => {
@@ -198,21 +298,16 @@ export const MoneyInput = ({
198
298
  event.target instanceof HTMLElement && event.target.tagName === "BUTTON";
199
299
 
200
300
  // Com o foco numa tecla, o Enter é o clique dela — mexer nisso quebraria
201
- // quem navega por Tab.
301
+ // quem navega por Tab. No visor e no resto do painel, o Enter usa o valor.
202
302
  if (event.key === "Enter" && !onButton) {
203
303
  event.preventDefault();
204
304
  apply();
205
305
  return;
206
306
  }
207
- if (event.key === "Backspace") {
307
+ // Fora do visor (numa tecla, por Tab), digitar ainda escreve na conta.
308
+ if (event.target !== visorRef.current && event.key.length === 1 && isCalcCharacter(event.key)) {
208
309
  event.preventDefault();
209
- setExpression((current) => current.slice(0, -1));
210
- return;
211
- }
212
- if (event.key.length === 1 && isCalcCharacter(event.key)) {
213
- event.preventDefault();
214
- // No teclado numérico o decimal é ponto; em reais, é vírgula.
215
- setExpression((current) => current + (event.key === "." ? "," : event.key));
310
+ write(event.key);
216
311
  }
217
312
  };
218
313
 
@@ -252,28 +347,39 @@ export const MoneyInput = ({
252
347
  </PopoverAnchor>
253
348
 
254
349
  <PopoverContent
255
- ref={panelRef}
256
350
  align="end"
257
351
  aria-label={t("calculator.title")}
258
352
  onKeyDown={handleKeyDown}
259
- // O foco fica no painel, e não na primeira tecla: assim o Enter usa o
260
- // valor em vez de apertar o "C".
353
+ // O foco vai para o visor, com o cursor no fim: é onde se digita, e é
354
+ // onde o Enter usa o valor em vez de apertar o "C".
261
355
  onOpenAutoFocus={(event) => {
262
356
  event.preventDefault();
263
- panelRef.current?.focus();
357
+ const visor = visorRef.current;
358
+ visor?.focus();
359
+ visor?.setSelectionRange(visor.value.length, visor.value.length);
264
360
  }}
265
- tabIndex={-1}
266
361
  className="w-72"
267
362
  >
363
+ {/*
364
+ O visor é a linha grande, e é editável: é nela que a pessoa clica
365
+ para trocar um número. O resultado fica embaixo, pequeno — é
366
+ consequência, não o que se mexe.
367
+ */}
268
368
  <div className="flex min-h-14 flex-col justify-between gap-1 rounded-md border border-border bg-muted/50 px-3 py-1.5 text-right">
269
- <span className="min-h-5 break-all font-mono text-xs text-muted-foreground">
270
- {toDisplayExpression(expression)}
271
- </span>
369
+ <input
370
+ ref={visorRef}
371
+ value={display}
372
+ onChange={handleVisorChange}
373
+ aria-label={t("calculator.title")}
374
+ autoComplete="off"
375
+ spellCheck={false}
376
+ className="w-full bg-transparent text-right font-mono text-base font-medium tabular-nums outline-none"
377
+ />
272
378
  {outcome.message ? (
273
379
  <span className="break-words text-xs text-destructive">{outcome.message}</span>
274
380
  ) : (
275
- <span className="break-all font-mono text-base font-medium tabular-nums">
276
- {formatMoneyBR(outcome.value ?? 0)}
381
+ <span className="min-h-4 font-mono text-xs text-muted-foreground">
382
+ {expression ? `= ${formatMoneyBR(outcome.value ?? 0)}` : ""}
277
383
  </span>
278
384
  )}
279
385
  </div>
@@ -285,6 +391,9 @@ export const MoneyInput = ({
285
391
  type="button"
286
392
  variant={key.role === "aux" ? "outline" : "secondary"}
287
393
  aria-label={key.labelKey ? t(key.labelKey) : undefined}
394
+ // O clique não tira o foco do visor: é a seleção de lá que a
395
+ // tecla substitui, e o cursor de lá que ela avança.
396
+ onMouseDown={(event) => event.preventDefault()}
288
397
  onClick={() => press(key)}
289
398
  className={cn(
290
399
  "h-8 px-0 font-mono text-sm",
@@ -292,7 +401,7 @@ export const MoneyInput = ({
292
401
  key.role === "aux" && "text-muted-foreground",
293
402
  )}
294
403
  >
295
- {key.label ?? (key.input ? toDisplayExpression(key.input) : <Delete />)}
404
+ {key.label ?? (key.input ? glyphOf(key.input) : <Delete />)}
296
405
  </Button>
297
406
  ))}
298
407
  </div>
@@ -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";