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 +1 -1
- package/src/_utils/calc.ts +116 -12
- package/src/components/ui/dialog.tsx +60 -1
- package/src/components/ui/help-hint.tsx +36 -0
- package/src/components/ui/hover-tip.tsx +81 -55
- package/src/components/ui/index.ts +2 -0
- package/src/components/ui/money-input.tsx +136 -27
- package/src/components/ui/period-nav.tsx +104 -0
- package/src/components/ui/tab-shortcuts.tsx +34 -60
- package/src/components/ui/tabs.tsx +21 -1
- package/src/features/shortcuts/shortcuts-screen.tsx +23 -5
- package/src/hooks/use-shortcut.ts +98 -6
- package/src/i18n/messages/en.ts +14 -4
- package/src/i18n/messages/pt.ts +14 -4
- package/src/index.ts +6 -2
package/package.json
CHANGED
package/src/_utils/calc.ts
CHANGED
|
@@ -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
|
|
101
|
+
* A conta em pedaços, com cada número já sabendo se é dinheiro.
|
|
88
102
|
*
|
|
89
|
-
*
|
|
90
|
-
*
|
|
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
|
-
|
|
94
|
-
.map((
|
|
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
|
-
*
|
|
113
|
-
*
|
|
114
|
-
* vírgula,
|
|
115
|
-
*
|
|
199
|
+
* **Dígitos soltos são centavos**, como no campo: quem digita `4250` no campo
|
|
200
|
+
* vê 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
|
-
|
|
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 {
|
|
4
|
-
import { useState } from "react";
|
|
5
|
-
|
|
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
|
-
/**
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
const
|
|
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
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
33
|
+
* Existe porque o `title` do HTML só 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
|
-
*
|
|
32
|
-
*
|
|
33
|
-
* e
|
|
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
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
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 [
|
|
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 = (
|
|
47
|
-
|
|
55
|
+
const show = (): void => {
|
|
56
|
+
if (closing.current) {
|
|
57
|
+
clearTimeout(closing.current);
|
|
58
|
+
closing.current = null;
|
|
59
|
+
}
|
|
60
|
+
setOpen(true);
|
|
61
|
+
};
|
|
48
62
|
|
|
49
|
-
|
|
63
|
+
const hide = (): void => {
|
|
64
|
+
closing.current = setTimeout(() => setOpen(false), CLOSE_GRACE_MS);
|
|
50
65
|
};
|
|
51
66
|
|
|
52
|
-
|
|
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
|
-
<
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
* `
|
|
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
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
277
|
+
erase();
|
|
183
278
|
return;
|
|
184
279
|
}
|
|
185
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
<
|
|
270
|
-
{
|
|
271
|
-
|
|
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="
|
|
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 ?
|
|
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 {
|
|
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
|
-
*
|
|
20
|
+
* `Alt` + `←`/`→` troca de aba. Não desenha nada.
|
|
15
21
|
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
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 só aparece na
|
|
24
|
+
* tela onde os dois existem. `Alt` + número, que já 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
|
-
*
|
|
31
|
-
*
|
|
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
|
+
* Dá 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
|
|
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
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
|
|
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,
|
|
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:
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
-
*
|
|
63
|
-
*
|
|
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
|
|
133
|
+
export const PREVIOUS_ARROW_KEY = "ArrowLeft";
|
|
134
|
+
export const NEXT_ARROW_KEY = "ArrowRight";
|
|
66
135
|
|
|
67
|
-
/** Como
|
|
68
|
-
export const
|
|
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
|
*
|
package/src/i18n/messages/en.ts
CHANGED
|
@@ -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
|
|
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:
|
|
160
|
-
|
|
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",
|
package/src/i18n/messages/pt.ts
CHANGED
|
@@ -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
|
|
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:
|
|
162
|
-
|
|
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
|
-
|
|
121
|
-
|
|
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";
|