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 +1 -1
- package/src/_utils/calendar.ts +104 -0
- package/src/components/ui/date-picker.tsx +96 -14
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,
|
|
@@ -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
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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 já 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={
|
|
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.
|