rl-core-front 0.16.1 → 0.16.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rl-core-front",
3
- "version": "0.16.1",
3
+ "version": "0.16.3",
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",
@@ -253,6 +253,110 @@ export const maskDay = (raw: string, locale: string, previous = ""): string => {
253
253
  return saida.join("/");
254
254
  };
255
255
 
256
+ /** Onde está o próximo dígito a partir de `posicao` — barra não se digita. */
257
+ const proximoDigito = (texto: string, posicao: number): number => {
258
+ let atual = posicao;
259
+
260
+ while (atual < texto.length && texto[atual] === "/") {
261
+ atual += 1;
262
+ }
263
+
264
+ return atual;
265
+ };
266
+
267
+ /**
268
+ * Um dígito digitado **sobre** uma data já escrita: troca o de baixo do cursor.
269
+ *
270
+ * É o que faz o cursor parado no começo de `01/01/2026` servir para algo. Sem
271
+ * isto, a tecla se enfiaria no texto: `3` no começo produziria `301/01/2026`,
272
+ * que a máscara — que lê campo a campo — remonta como `30/10/1202`, uma data
273
+ * que ninguém digitou.
274
+ *
275
+ * Trabalha **por campo**, e não por caractere do texto: é o campo que tem teto
276
+ * (dia até 31, mês até 12) e é ele que diz quando a tecla seguinte muda de
277
+ * lugar. Duas saídas, conforme o dígito caiba ou não:
278
+ *
279
+ * - cabe: é correção, e o resto do campo fica de pé — `1` sobre o `2` de
280
+ * `2026` dá `1026`, e não um ano recomeçado do zero;
281
+ * - não cabe: o campo recomeça daquele dígito, como se estivesse sendo
282
+ * digitado agora — `9` sobre o dia `11` dá `09`, porque 9 não começa dia
283
+ * nenhum, e o cursor já passa para o mês.
284
+ *
285
+ * A posição devolvida é a do próximo dígito a digitar, pulando a barra: quem
286
+ * teclar `3`, `1`, `0`, `8` escreve 31/08 sem tocar em seta nenhuma. Campo que
287
+ * ficou pela metade (o mês `1`, que ainda pode virar 10, 11 ou 12) mantém o
288
+ * cursor nele, esperando o segundo dígito.
289
+ */
290
+ export const overtypeDay = (
291
+ texto: string,
292
+ posicao: number,
293
+ digito: string,
294
+ locale: string,
295
+ ): { texto: string; posicao: number } => {
296
+ const order = dayFieldOrder(locale);
297
+ const campos = texto.split("/");
298
+
299
+ // Só sobre uma data inteira. Meia data é o campo sendo preenchido, e ali a
300
+ // digitação normal já escreve no fim.
301
+ if (campos.length !== order.length) {
302
+ return { texto, posicao };
303
+ }
304
+
305
+ /*
306
+ Em que campo o cursor está, e em que dígito dele.
307
+
308
+ O cursor em cima da barra conta como o começo do campo seguinte — é onde
309
+ ele está depois de fechar o dia. E o cursor no fim de um campo que ainda
310
+ não está cheio continua nesse campo: é o `1` do mês esperando o `2`.
311
+ */
312
+ let base = 0;
313
+ let alvo = -1;
314
+ let offset = 0;
315
+
316
+ for (let indice = 0; indice < campos.length; indice += 1) {
317
+ const fim = base + campos[indice].length;
318
+ const cheio = campos[indice].length >= DAY_FIELD_SIZES[order[indice]];
319
+
320
+ if (posicao < fim || (posicao === fim && !cheio)) {
321
+ alvo = indice;
322
+ offset = Math.max(0, posicao - base);
323
+ break;
324
+ }
325
+
326
+ base = fim + 1;
327
+ }
328
+
329
+ // Depois do último dígito não há o que sobrescrever, e acrescentar faria a
330
+ // data crescer para além do formato.
331
+ if (alvo === -1) {
332
+ return { texto, posicao };
333
+ }
334
+
335
+ const tamanho = DAY_FIELD_SIZES[order[alvo]];
336
+ const max = DAY_FIELD_MAX[order[alvo]];
337
+ const campo = campos[alvo];
338
+ const trocado = `${campo.slice(0, offset)}${digito}${campo.slice(offset + 1)}`;
339
+ const cabe =
340
+ trocado.length <= tamanho && Number(trocado) >= 1 && Number(trocado) <= max;
341
+ const valor = cabe ? trocado : fitDayField(digito, max, tamanho, true).value;
342
+ const novo = campos
343
+ .map((atual, indice) => (indice === alvo ? valor : atual))
344
+ .join("/");
345
+
346
+ // Quantos dígitos do campo a tecla consumiu: um, quando corrigiu; o campo
347
+ // todo, quando ele recomeçou (o `9` que já vira `09`).
348
+ const consumido = cabe ? offset + 1 : valor.length;
349
+ const parou = base + consumido;
350
+
351
+ return {
352
+ texto: novo,
353
+ posicao:
354
+ consumido >= valor.length && valor.length === tamanho
355
+ ? proximoDigito(novo, parou)
356
+ : parou,
357
+ };
358
+ };
359
+
256
360
  /**
257
361
  * O que foi digitado vira `"yyyy-MM-dd"`, ou `null` enquanto não for uma data.
258
362
  *
@@ -1,8 +1,8 @@
1
1
  "use client";
2
2
 
3
3
  import { Calendar, ChevronLeft, ChevronRight } from "lucide-react";
4
- import type { JSX } from "react";
5
- import { useState } from "react";
4
+ import type { JSX, KeyboardEvent } from "react";
5
+ import { useLayoutEffect, useRef, useState } from "react";
6
6
 
7
7
  import {
8
8
  addMonths,
@@ -14,6 +14,7 @@ import {
14
14
  maskDay,
15
15
  monthGrid,
16
16
  monthLabel,
17
+ overtypeDay,
17
18
  parseTypedDay,
18
19
  today,
19
20
  toIsoDay,
@@ -54,6 +55,13 @@ export interface DatePickerProps {
54
55
  * O campo é **digitável**: `1108` já é 11 de agosto, e quem sabe a data não
55
56
  * precisa navegar meses no calendário para dizê-la. O calendário continua ali,
56
57
  * no ícone — é o caminho de quem procura o dia, não de quem já o conhece.
58
+ *
59
+ * Focar o campo põe o cursor **antes do dia**, e o dígito digitado sobrescreve
60
+ * o que está embaixo dele em vez de empurrar o resto: `3` sobre `01/01/2026`
61
+ * dá `31/01/2026` com o cursor já no mês. Com o cursor solto no fim do ano —
62
+ * onde o navegador o deixa — trocar a data exigia apagar dez caracteres
63
+ * primeiro; marcar tudo resolvia isso, mas obrigava a redigitar mês e ano para
64
+ * corrigir só o dia.
57
65
  */
58
66
  export function DatePicker({
59
67
  value,
@@ -76,23 +84,37 @@ export function DatePicker({
76
84
  const selected = fromIsoDay(value);
77
85
  const [month, setMonth] = useState<Date>(() => selected ?? today());
78
86
 
87
+ const inputRef = useRef<HTMLInputElement | null>(null);
88
+ /*
89
+ Onde o cursor deve ficar depois do próximo render.
90
+
91
+ O input é controlado, então quem escreve o valor é o React — e ele repõe o
92
+ cursor no fim. A posição precisa ser reaplicada depois da pintura, e por
93
+ isso vive num ref: em estado, ela causaria um render só para dizer onde o
94
+ cursor está.
95
+ */
96
+ const caret = useRef<number | null>(null);
97
+
98
+ useLayoutEffect(() => {
99
+ if (caret.current === null || !inputRef.current) {
100
+ return;
101
+ }
102
+
103
+ inputRef.current.setSelectionRange(caret.current, caret.current);
104
+ caret.current = null;
105
+ });
106
+
79
107
  const pickDay = (day: Date): void => {
80
108
  onChange(toIsoDay(day));
81
109
  setTyped(null);
82
110
  setOpen(false);
83
111
  };
84
112
 
85
- /**
86
- * Cada tecla: a máscara põe as barras, e a data sai assim que existir.
87
- *
88
- * O ano em branco é o do dia que está no campo — ou o de hoje, quando não há
89
- * nenhum. Campo esvaziado limpa o valor: apagar é uma escolha, não um
90
- * rascunho pela metade.
91
- */
92
- const digitar = (raw: string): void => {
93
- // O texto que estava lá antes: é ele que diz se a tecla foi um dígito novo
94
- // ou um apagar — e o campo se comporta diferente nos dois casos.
95
- const texto = maskDay(raw, locale, typed ?? (value ? formatDay(value, locale) : ""));
113
+ /** O que o campo mostra agora: o rascunho, ou a data gravada. */
114
+ const escrito = (): string => typed ?? (value ? formatDay(value, locale) : "");
115
+
116
+ /** O texto vira rascunho e, se for uma data, valor. */
117
+ const aplicar = (texto: string): void => {
96
118
  setTyped(texto);
97
119
 
98
120
  if (texto === "") {
@@ -113,6 +135,50 @@ export function DatePicker({
113
135
  }
114
136
  };
115
137
 
138
+ /**
139
+ * Cada tecla: a máscara põe as barras, e a data sai assim que existir.
140
+ *
141
+ * O ano em branco é o do dia que está no campo — ou o de hoje, quando não há
142
+ * nenhum. Campo esvaziado limpa o valor: apagar é uma escolha, não um
143
+ * rascunho pela metade.
144
+ */
145
+ const digitar = (raw: string): void => {
146
+ // O texto que estava lá antes: é ele que diz se a tecla foi um dígito novo
147
+ // ou um apagar — e o campo se comporta diferente nos dois casos.
148
+ aplicar(maskDay(raw, locale, escrito()));
149
+ };
150
+
151
+ /**
152
+ * Dígito sobre uma data já escrita: troca o de baixo do cursor.
153
+ *
154
+ * Só quando a data está completa e nada está selecionado. Data pela metade é
155
+ * o campo sendo preenchido, e ali o cursor já está no fim; com um trecho
156
+ * marcado, digitar substitui o trecho, que é o que qualquer campo de texto
157
+ * faz e ninguém espera diferente.
158
+ */
159
+ const sobrescrever = (event: KeyboardEvent<HTMLInputElement>): void => {
160
+ const campo = event.currentTarget;
161
+ const inicio = campo.selectionStart ?? 0;
162
+
163
+ if (
164
+ !/^\d$/.test(event.key) ||
165
+ event.ctrlKey ||
166
+ event.metaKey ||
167
+ event.altKey ||
168
+ campo.value.length < dayPlaceholder(locale).length ||
169
+ (campo.selectionEnd ?? inicio) !== inicio
170
+ ) {
171
+ return;
172
+ }
173
+
174
+ event.preventDefault();
175
+
176
+ const { texto, posicao } = overtypeDay(campo.value, inicio, event.key, locale);
177
+
178
+ caret.current = posicao;
179
+ aplicar(texto);
180
+ };
181
+
116
182
  const dayClass = (day: Date): string => {
117
183
  if (selected && isSameDay(day, selected)) {
118
184
  return "bg-primary text-primary-foreground font-semibold";
@@ -137,14 +203,30 @@ export function DatePicker({
137
203
  )}
138
204
  >
139
205
  <input
206
+ ref={inputRef}
140
207
  aria-label={label}
141
- value={typed ?? (value ? formatDay(value, locale) : "")}
208
+ value={escrito()}
142
209
  disabled={disabled}
143
210
  // Só dígitos, e o teclado do celular abre no numérico: a máscara é
144
211
  // quem escreve as barras.
145
212
  inputMode="numeric"
146
213
  placeholder={placeholder ?? dayPlaceholder(locale)}
147
214
  onChange={(event) => digitar(event.target.value)}
215
+ onKeyDown={sobrescrever}
216
+ // Chegou o foco, o cursor vai para antes do dia: é por ele que se
217
+ // começa a escrever uma data, e o navegador deixaria o cursor no
218
+ // fim do ano.
219
+ onFocus={(event) => event.currentTarget.setSelectionRange(0, 0)}
220
+ // O clique é o nosso, e não o do navegador: o dele põe o cursor
221
+ // onde se clicou e desfaz, no `mouseup`, a seleção que o foco
222
+ // acabou de fazer. Só no primeiro clique — dentro do campo já
223
+ // focado, clicar e arrastar volta a valer.
224
+ onMouseDown={(event) => {
225
+ if (document.activeElement !== event.currentTarget) {
226
+ event.preventDefault();
227
+ event.currentTarget.focus();
228
+ }
229
+ }}
148
230
  // Sai do campo e o rascunho some: `11/0` não é data, e deixá-lo
149
231
  // escrito faria o campo mostrar uma coisa e o formulário guardar
150
232
  // outra.