rl-core-front 0.16.2 → 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 +1 -1
- package/src/_utils/calendar.ts +104 -0
- package/src/components/ui/date-picker.tsx +85 -22
package/package.json
CHANGED
package/src/_utils/calendar.ts
CHANGED
|
@@ -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,
|
|
@@ -55,10 +56,12 @@ export interface DatePickerProps {
|
|
|
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.
|
|
57
58
|
*
|
|
58
|
-
* Focar o campo
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
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.
|
|
62
65
|
*/
|
|
63
66
|
export function DatePicker({
|
|
64
67
|
value,
|
|
@@ -81,23 +84,37 @@ export function DatePicker({
|
|
|
81
84
|
const selected = fromIsoDay(value);
|
|
82
85
|
const [month, setMonth] = useState<Date>(() => selected ?? today());
|
|
83
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
|
+
|
|
84
107
|
const pickDay = (day: Date): void => {
|
|
85
108
|
onChange(toIsoDay(day));
|
|
86
109
|
setTyped(null);
|
|
87
110
|
setOpen(false);
|
|
88
111
|
};
|
|
89
112
|
|
|
90
|
-
/**
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
* rascunho pela metade.
|
|
96
|
-
*/
|
|
97
|
-
const digitar = (raw: string): void => {
|
|
98
|
-
// O texto que estava lá antes: é ele que diz se a tecla foi um dígito novo
|
|
99
|
-
// ou um apagar — e o campo se comporta diferente nos dois casos.
|
|
100
|
-
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 já for uma data, valor. */
|
|
117
|
+
const aplicar = (texto: string): void => {
|
|
101
118
|
setTyped(texto);
|
|
102
119
|
|
|
103
120
|
if (texto === "") {
|
|
@@ -118,6 +135,50 @@ export function DatePicker({
|
|
|
118
135
|
}
|
|
119
136
|
};
|
|
120
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
|
+
|
|
121
182
|
const dayClass = (day: Date): string => {
|
|
122
183
|
if (selected && isSameDay(day, selected)) {
|
|
123
184
|
return "bg-primary text-primary-foreground font-semibold";
|
|
@@ -142,18 +203,20 @@ export function DatePicker({
|
|
|
142
203
|
)}
|
|
143
204
|
>
|
|
144
205
|
<input
|
|
206
|
+
ref={inputRef}
|
|
145
207
|
aria-label={label}
|
|
146
|
-
value={
|
|
208
|
+
value={escrito()}
|
|
147
209
|
disabled={disabled}
|
|
148
210
|
// Só dígitos, e o teclado do celular abre no numérico: a máscara é
|
|
149
211
|
// quem escreve as barras.
|
|
150
212
|
inputMode="numeric"
|
|
151
213
|
placeholder={placeholder ?? dayPlaceholder(locale)}
|
|
152
214
|
onChange={(event) => digitar(event.target.value)}
|
|
153
|
-
|
|
154
|
-
//
|
|
155
|
-
//
|
|
156
|
-
|
|
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)}
|
|
157
220
|
// O clique é o nosso, e não o do navegador: o dele põe o cursor
|
|
158
221
|
// onde se clicou e desfaz, no `mouseup`, a seleção que o foco
|
|
159
222
|
// acabou de fazer. Só no primeiro clique — dentro do campo já
|