rl-core-front 0.19.1 → 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.1",
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
  }
@@ -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>