rl-core-front 0.15.4 → 0.15.5
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
package/src/_utils/calendar.ts
CHANGED
|
@@ -66,16 +66,19 @@ export const today = (): Date => {
|
|
|
66
66
|
};
|
|
67
67
|
|
|
68
68
|
/**
|
|
69
|
-
* Grade do mês: 6 semanas × 7 dias, começando
|
|
69
|
+
* Grade do mês: 6 semanas × 7 dias, começando no domingo.
|
|
70
|
+
*
|
|
71
|
+
* Domingo na primeira coluna porque é como o calendário é lido aqui — o de
|
|
72
|
+
* parede, o do celular e o do banco começam nele, e uma grade que começa na
|
|
73
|
+
* segunda faz quem procura o dia 12 contar coluna por coluna.
|
|
70
74
|
*
|
|
71
75
|
* Seis semanas fixas de propósito — com 5 ou 6 conforme o mês, o painel muda de
|
|
72
76
|
* altura ao navegar e o calendário "pula" na tela.
|
|
73
77
|
*/
|
|
74
78
|
export const monthGrid = (month: Date): Date[] => {
|
|
75
79
|
const first = dayOf(month.getFullYear(), month.getMonth(), 1);
|
|
76
|
-
// getDay(): 0 = domingo
|
|
77
|
-
const
|
|
78
|
-
const start = addDays(first, -offset);
|
|
80
|
+
// getDay(): 0 = domingo, que é a primeira coluna.
|
|
81
|
+
const start = addDays(first, -first.getDay());
|
|
79
82
|
return Array.from({ length: 42 }, (_, index) => addDays(start, index));
|
|
80
83
|
};
|
|
81
84
|
|
|
@@ -85,12 +88,12 @@ export const monthLabel = (month: Date, locale: string): string =>
|
|
|
85
88
|
month,
|
|
86
89
|
);
|
|
87
90
|
|
|
88
|
-
/** Iniciais dos dias da semana, de
|
|
91
|
+
/** Iniciais dos dias da semana, de domingo a sábado, no locale. */
|
|
89
92
|
export const weekdayLabels = (locale: string): string[] => {
|
|
90
93
|
const formatter = new Intl.DateTimeFormat(locale, { weekday: "short" });
|
|
91
|
-
//
|
|
94
|
+
// Dia 0 de junho é 31/05/2026, um domingo — âncora só para varrer a semana.
|
|
92
95
|
return Array.from({ length: 7 }, (_, index) =>
|
|
93
|
-
formatter.format(dayOf(2026, 5,
|
|
96
|
+
formatter.format(dayOf(2026, 5, index)).replace(".", ""),
|
|
94
97
|
);
|
|
95
98
|
};
|
|
96
99
|
|
|
@@ -101,3 +104,204 @@ export const formatDay = (value: string, locale: string): string => {
|
|
|
101
104
|
? new Intl.DateTimeFormat(locale, { dateStyle: "short" }).format(date)
|
|
102
105
|
: value;
|
|
103
106
|
};
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* A ordem em que o locale escreve dia, mês e ano.
|
|
110
|
+
*
|
|
111
|
+
* Sai do próprio `Intl` e não de uma tabela: `pt-BR` escreve 11/08/2026 e
|
|
112
|
+
* `en-US`, 08/11/2026 — a mesma sequência de dígitos significa dias
|
|
113
|
+
* diferentes, e é ela que o campo digitado precisa respeitar.
|
|
114
|
+
*/
|
|
115
|
+
export const dayFieldOrder = (locale: string): ("day" | "month" | "year")[] =>
|
|
116
|
+
new Intl.DateTimeFormat(locale, { dateStyle: "short" })
|
|
117
|
+
.formatToParts(dayOf(2026, 7, 11))
|
|
118
|
+
.map((part) => part.type)
|
|
119
|
+
.filter((type): type is "day" | "month" | "year" =>
|
|
120
|
+
type === "day" || type === "month" || type === "year",
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
/** `dd/mm/aaaa` no locale — o que o campo mostra enquanto está vazio. */
|
|
124
|
+
export const dayPlaceholder = (locale: string): string => {
|
|
125
|
+
const letra = { day: "dd", month: "mm", year: locale.startsWith("pt") ? "aaaa" : "yyyy" };
|
|
126
|
+
|
|
127
|
+
return dayFieldOrder(locale)
|
|
128
|
+
.map((field) => letra[field])
|
|
129
|
+
.join("/");
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
/** Quantos dígitos cabem em cada campo da data, e o maior valor de cada um. */
|
|
133
|
+
const DAY_FIELD_SIZES: Record<"day" | "month" | "year", number> = {
|
|
134
|
+
day: 2,
|
|
135
|
+
month: 2,
|
|
136
|
+
year: 4,
|
|
137
|
+
};
|
|
138
|
+
const DAY_FIELD_MAX: Record<"day" | "month" | "year", number> = {
|
|
139
|
+
day: 31,
|
|
140
|
+
month: 12,
|
|
141
|
+
// O ano não tem teto que ajude enquanto se digita: 2 é o começo de 2026.
|
|
142
|
+
year: Infinity,
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
/** O campo formado e quantos dígitos ele consumiu — o resto vai para o próximo. */
|
|
146
|
+
interface FittedField {
|
|
147
|
+
value: string;
|
|
148
|
+
used: number;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* O campo enquanto ele é digitado, dentro do que existe no calendário.
|
|
153
|
+
*
|
|
154
|
+
* Três regras, todas para o campo nunca mostrar o que não existe:
|
|
155
|
+
*
|
|
156
|
+
* - dígito que não pode **começar** valor nenhum fecha o campo com zero na
|
|
157
|
+
* frente — no mês, `3` só poderia virar 30 a 39, então já vira `03`;
|
|
158
|
+
* - dígito que estoura o par não é engolido, transborda: `3` e `2` no dia
|
|
159
|
+
* viram `3/2`, que é o que quem digitou `3` e `2` queria dizer;
|
|
160
|
+
* - `00` não é dia nem mês.
|
|
161
|
+
*
|
|
162
|
+
* O zero na frente só entra quando se está **digitando**. Apagando, ele
|
|
163
|
+
* devolveria na hora o dígito recém-tirado, e o campo ficaria impossível de
|
|
164
|
+
* corrigir.
|
|
165
|
+
*/
|
|
166
|
+
const fitDayField = (
|
|
167
|
+
digits: string,
|
|
168
|
+
max: number,
|
|
169
|
+
size: number,
|
|
170
|
+
autoZero: boolean,
|
|
171
|
+
): FittedField => {
|
|
172
|
+
const primeiro = digits.slice(0, 1);
|
|
173
|
+
|
|
174
|
+
if (autoZero && primeiro !== "" && Number(primeiro) * 10 > max) {
|
|
175
|
+
return { value: `0${primeiro}`, used: 1 };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const cheio = digits.slice(0, size);
|
|
179
|
+
|
|
180
|
+
// `00` não é dia nem mês, e o segundo zero não serve para o campo seguinte:
|
|
181
|
+
// some, em vez de virar um mês zero logo ali.
|
|
182
|
+
if (cheio.length === size && Number(cheio) === 0) {
|
|
183
|
+
return { value: cheio.slice(0, size - 1), used: size };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if (cheio.length === size && Number(cheio) > max) {
|
|
187
|
+
return { value: cheio.slice(0, size - 1), used: size - 1 };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
return { value: cheio, used: cheio.length };
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Põe as barras enquanto se digita: `1108` vira `11/08`.
|
|
195
|
+
*
|
|
196
|
+
* Campo a campo, e não recontando os dígitos do começo: apagar o `3` de
|
|
197
|
+
* `31/08/2026` deixa `1/08/2026`, e não `10/82/026` — o que sobra de um campo
|
|
198
|
+
* cheio transborda para o seguinte, mas nada é puxado de volta.
|
|
199
|
+
*
|
|
200
|
+
* O locale entra porque é ele que diz qual campo é o dia e qual é o mês, e os
|
|
201
|
+
* dois têm tetos diferentes: `13` é dia e não é mês. O texto anterior entra
|
|
202
|
+
* para separar quem digita de quem apaga — ver `fitDayField`.
|
|
203
|
+
*/
|
|
204
|
+
export const maskDay = (raw: string, locale: string, previous = ""): string => {
|
|
205
|
+
const order = dayFieldOrder(locale);
|
|
206
|
+
const partes = raw.replace(/[^\d/]/g, "").split("/").slice(0, order.length);
|
|
207
|
+
const apagando = raw.length < previous.length;
|
|
208
|
+
const saida: string[] = [];
|
|
209
|
+
let resto = "";
|
|
210
|
+
|
|
211
|
+
for (let posicao = 0; posicao < order.length; posicao += 1) {
|
|
212
|
+
const campo = order[posicao];
|
|
213
|
+
const tamanho = DAY_FIELD_SIZES[campo];
|
|
214
|
+
const digits = `${resto}${partes[posicao] ?? ""}`.replace(/\D/g, "");
|
|
215
|
+
const { value, used } = fitDayField(
|
|
216
|
+
digits,
|
|
217
|
+
DAY_FIELD_MAX[campo],
|
|
218
|
+
tamanho,
|
|
219
|
+
!apagando,
|
|
220
|
+
);
|
|
221
|
+
|
|
222
|
+
saida.push(value);
|
|
223
|
+
|
|
224
|
+
/*
|
|
225
|
+
O que sobra só empurra os campos seguintes quando eles ainda estão
|
|
226
|
+
vazios.
|
|
227
|
+
|
|
228
|
+
Digitando do zero, empurrar é o que faz `1108` virar `11/08`. Mas
|
|
229
|
+
corrigindo o dia de uma data já escrita, o dígito recusado empurrava o
|
|
230
|
+
resto inteiro: trocar o `31` de `31/02/2056` por `32` devolvia
|
|
231
|
+
`3/02/0220`, com mês e ano remontados a partir do que sobrou. Aqui ele
|
|
232
|
+
simplesmente não entra — a data continua a que estava, com o campo em
|
|
233
|
+
correção mais curto.
|
|
234
|
+
|
|
235
|
+
A exceção é o excedente de tamanho: quem digitou mais dígitos do que o
|
|
236
|
+
campo comporta (colando, ou apagando uma barra) quis mesmo dizer o campo
|
|
237
|
+
seguinte.
|
|
238
|
+
*/
|
|
239
|
+
const temConteudoDepois = partes
|
|
240
|
+
.slice(posicao + 1)
|
|
241
|
+
.some((parte) => parte !== "");
|
|
242
|
+
const sobraPorTamanho = digits.length > tamanho;
|
|
243
|
+
|
|
244
|
+
resto = temConteudoDepois && !sobraPorTamanho ? "" : digits.slice(used);
|
|
245
|
+
|
|
246
|
+
// Sem transbordo e sem mais nada digitado, a data acaba aqui: acrescentar
|
|
247
|
+
// um campo vazio escreveria a barra que ninguém pediu.
|
|
248
|
+
if (resto === "" && partes.length <= posicao + 1) {
|
|
249
|
+
break;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
return saida.join("/");
|
|
254
|
+
};
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* O que foi digitado vira `"yyyy-MM-dd"`, ou `null` enquanto não for uma data.
|
|
258
|
+
*
|
|
259
|
+
* Lê os campos separados pelas barras, na ordem do locale: `1/8/2026` é uma
|
|
260
|
+
* data tão boa quanto `01/08/2026`, e exigir o zero à esquerda faria o campo
|
|
261
|
+
* recusar o que ele mesmo acabou de mostrar.
|
|
262
|
+
*
|
|
263
|
+
* O ano pode faltar: `1108` é 11 de agosto do ano de referência — quase todo
|
|
264
|
+
* lançamento é do ano corrente, e obrigar a digitar `2026` toda vez é o tipo
|
|
265
|
+
* de exigência que faz o campo ser abandonado pelo calendário. Ano começado
|
|
266
|
+
* (`202`) ainda não é ano, e não vale como digitado.
|
|
267
|
+
*
|
|
268
|
+
* A conferência é de existência, não de formato: `31/02` casa com a máscara e
|
|
269
|
+
* não existe, e o `Date` do JavaScript o transformaria em 03/03 em silêncio.
|
|
270
|
+
*/
|
|
271
|
+
export const parseTypedDay = (
|
|
272
|
+
text: string,
|
|
273
|
+
locale: string,
|
|
274
|
+
fallbackYear: number,
|
|
275
|
+
): string | null => {
|
|
276
|
+
const campos = maskDay(text, locale).split("/");
|
|
277
|
+
|
|
278
|
+
if (campos.length < 2) {
|
|
279
|
+
return null;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const order = dayFieldOrder(locale);
|
|
283
|
+
const campoDe = (field: "day" | "month" | "year"): string =>
|
|
284
|
+
campos[order.indexOf(field)] ?? "";
|
|
285
|
+
|
|
286
|
+
const anoDigitado = campoDe("year");
|
|
287
|
+
|
|
288
|
+
if (anoDigitado !== "" && anoDigitado.length < 4) {
|
|
289
|
+
return null;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
const day = Number(campoDe("day"));
|
|
293
|
+
const month = Number(campoDe("month"));
|
|
294
|
+
const year = anoDigitado === "" ? fallbackYear : Number(anoDigitado);
|
|
295
|
+
|
|
296
|
+
if (!day || !month) {
|
|
297
|
+
return null;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const date = dayOf(year, month - 1, day);
|
|
301
|
+
const existe =
|
|
302
|
+
date.getFullYear() === year &&
|
|
303
|
+
date.getMonth() === month - 1 &&
|
|
304
|
+
date.getDate() === day;
|
|
305
|
+
|
|
306
|
+
return existe ? toIsoDay(date) : null;
|
|
307
|
+
};
|
|
@@ -79,6 +79,14 @@ interface DataTableProps<T extends RowData> {
|
|
|
79
79
|
loading?: boolean;
|
|
80
80
|
/** Ações à esquerda da toolbar (ex.: botão "Novo"). */
|
|
81
81
|
toolbar?: React.ReactNode;
|
|
82
|
+
/**
|
|
83
|
+
* Ações à direita, antes de "Filtros" e "Colunas".
|
|
84
|
+
*
|
|
85
|
+
* É onde mora o que age sobre **a listagem inteira** — exportar, por
|
|
86
|
+
* exemplo. À esquerda ficam as ações de criar; misturar as duas coisas na
|
|
87
|
+
* mesma ponta faz o botão de exportar parecer mais um "novo".
|
|
88
|
+
*/
|
|
89
|
+
toolbarEnd?: React.ReactNode;
|
|
82
90
|
pageSizeOptions?: number[];
|
|
83
91
|
/** Paginação no servidor: informe rowCount, pagination e onPaginationChange. */
|
|
84
92
|
manualPagination?: boolean;
|
|
@@ -115,6 +123,18 @@ interface DataTableProps<T extends RowData> {
|
|
|
115
123
|
* indistinguível de bug, e manda o usuário procurar defeito onde não há.
|
|
116
124
|
*/
|
|
117
125
|
emptyMessage?: string;
|
|
126
|
+
/**
|
|
127
|
+
* Esconde o rodapé de contagem, paginação e "por página".
|
|
128
|
+
*
|
|
129
|
+
* Para a listagem que já vem inteira numa resposta — um cadastro de vinte
|
|
130
|
+
* linhas não precisa de página. Sem o rodapé a tabela mostra **todas** as
|
|
131
|
+
* linhas, senão esconder a navegação esconderia registros.
|
|
132
|
+
*
|
|
133
|
+
* Ignorado quando a paginação é do servidor (`manualPagination`): ali a
|
|
134
|
+
* tabela só tem em mãos a página atual, e tirar o rodapé faria as outras
|
|
135
|
+
* sumirem sem aviso.
|
|
136
|
+
*/
|
|
137
|
+
hideFooter?: boolean;
|
|
118
138
|
/**
|
|
119
139
|
* Arrastar a linha pela alça para reordenar a listagem.
|
|
120
140
|
*
|
|
@@ -172,6 +192,7 @@ export function DataTable<T extends RowData>({
|
|
|
172
192
|
storageKey,
|
|
173
193
|
loading,
|
|
174
194
|
toolbar,
|
|
195
|
+
toolbarEnd,
|
|
175
196
|
pageSizeOptions = [10, 20, 50],
|
|
176
197
|
manualPagination,
|
|
177
198
|
rowCount,
|
|
@@ -184,6 +205,7 @@ export function DataTable<T extends RowData>({
|
|
|
184
205
|
filters,
|
|
185
206
|
hiddenFilterFields,
|
|
186
207
|
emptyMessage,
|
|
208
|
+
hideFooter = false,
|
|
187
209
|
onRowReorder,
|
|
188
210
|
}: DataTableProps<T>): JSX.Element {
|
|
189
211
|
const { t } = useI18n();
|
|
@@ -228,6 +250,16 @@ export function DataTable<T extends RowData>({
|
|
|
228
250
|
},
|
|
229
251
|
);
|
|
230
252
|
|
|
253
|
+
/*
|
|
254
|
+
Rodapé escondido é lista inteira na tela: sem a navegação, um `pageSize` de
|
|
255
|
+
10 esconderia da décima primeira linha em diante sem nada dizer. Não vale
|
|
256
|
+
para a paginação do servidor, onde a tabela só tem a página atual em mãos.
|
|
257
|
+
*/
|
|
258
|
+
const semRodape = hideFooter && !manualPagination;
|
|
259
|
+
const paginacaoLocal = semRodape
|
|
260
|
+
? { pageIndex: 0, pageSize: Math.max(data.length, 1) }
|
|
261
|
+
: internalPagination;
|
|
262
|
+
|
|
231
263
|
const table = useTable({
|
|
232
264
|
features: dataTableFeatures,
|
|
233
265
|
data,
|
|
@@ -236,7 +268,7 @@ export function DataTable<T extends RowData>({
|
|
|
236
268
|
state: {
|
|
237
269
|
columnVisibility,
|
|
238
270
|
sorting: manualSorting ? sorting : internalSorting,
|
|
239
|
-
pagination: manualPagination ? pagination :
|
|
271
|
+
pagination: manualPagination ? pagination : paginacaoLocal,
|
|
240
272
|
},
|
|
241
273
|
onColumnVisibilityChange: (updater) => {
|
|
242
274
|
const next =
|
|
@@ -404,6 +436,7 @@ export function DataTable<T extends RowData>({
|
|
|
404
436
|
{toolbar}
|
|
405
437
|
</div>
|
|
406
438
|
<div className="flex flex-wrap items-center gap-2">
|
|
439
|
+
{toolbarEnd}
|
|
407
440
|
{/* Filtro antes de colunas: filtrar é a ação do dia a dia, escolher
|
|
408
441
|
coluna se faz uma vez e a tela guarda. */}
|
|
409
442
|
{filtersEnabled && (
|
|
@@ -594,73 +627,75 @@ export function DataTable<T extends RowData>({
|
|
|
594
627
|
uma vez fica na ponta, e a navegação, que se usa o tempo todo, no
|
|
595
628
|
centro. Em tela estreita eles empilham em vez de espremer.
|
|
596
629
|
*/}
|
|
597
|
-
|
|
598
|
-
<
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
<
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
item
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
<Button
|
|
640
|
-
variant="ghost"
|
|
641
|
-
size="icon"
|
|
642
|
-
aria-label={t("table.nextPage")}
|
|
643
|
-
disabled={!table.getCanNextPage()}
|
|
644
|
-
onClick={() => table.nextPage()}
|
|
645
|
-
>
|
|
646
|
-
<ChevronRight className="h-4 w-4" />
|
|
647
|
-
</Button>
|
|
648
|
-
</div>
|
|
630
|
+
{!semRodape && (
|
|
631
|
+
<div className="flex flex-col items-center gap-3 border-t border-border px-4 py-3 text-sm text-muted-foreground sm:flex-row sm:justify-between">
|
|
632
|
+
<span className="sm:flex-1">
|
|
633
|
+
{total} {t("table.results")}
|
|
634
|
+
</span>
|
|
635
|
+
|
|
636
|
+
<div className="flex items-center gap-1">
|
|
637
|
+
<Button
|
|
638
|
+
variant="ghost"
|
|
639
|
+
size="icon"
|
|
640
|
+
aria-label={t("table.previousPage")}
|
|
641
|
+
disabled={!table.getCanPreviousPage()}
|
|
642
|
+
onClick={() => table.previousPage()}
|
|
643
|
+
>
|
|
644
|
+
<ChevronLeft className="h-4 w-4" />
|
|
645
|
+
</Button>
|
|
646
|
+
|
|
647
|
+
{pageRange(pageIndex, pageCount).map((item, posicao) =>
|
|
648
|
+
item === "gap" ? (
|
|
649
|
+
// O buraco não é botão: `key` pela posição porque duas elipses
|
|
650
|
+
// são indistinguíveis entre si.
|
|
651
|
+
<span
|
|
652
|
+
key={`gap-${posicao}`}
|
|
653
|
+
className="px-1 text-muted-foreground"
|
|
654
|
+
>
|
|
655
|
+
…
|
|
656
|
+
</span>
|
|
657
|
+
) : (
|
|
658
|
+
<Button
|
|
659
|
+
key={item}
|
|
660
|
+
variant={item === pageIndex + 1 ? "default" : "ghost"}
|
|
661
|
+
size="icon"
|
|
662
|
+
// Quadrado de 40px como base, mas cresce com o número: a
|
|
663
|
+
// página 128 não pode vazar do botão.
|
|
664
|
+
className="w-auto min-w-10 px-2 tabular-nums"
|
|
665
|
+
aria-current={item === pageIndex + 1 ? "page" : undefined}
|
|
666
|
+
onClick={() => table.setPageIndex(item - 1)}
|
|
667
|
+
>
|
|
668
|
+
{item}
|
|
669
|
+
</Button>
|
|
670
|
+
),
|
|
671
|
+
)}
|
|
649
672
|
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
<
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
673
|
+
<Button
|
|
674
|
+
variant="ghost"
|
|
675
|
+
size="icon"
|
|
676
|
+
aria-label={t("table.nextPage")}
|
|
677
|
+
disabled={!table.getCanNextPage()}
|
|
678
|
+
onClick={() => table.nextPage()}
|
|
679
|
+
>
|
|
680
|
+
<ChevronRight className="h-4 w-4" />
|
|
681
|
+
</Button>
|
|
682
|
+
</div>
|
|
683
|
+
|
|
684
|
+
<div className="sm:flex-1 sm:text-right">
|
|
685
|
+
<select
|
|
686
|
+
value={pageSize}
|
|
687
|
+
onChange={(e) => table.setPageSize(Number(e.target.value))}
|
|
688
|
+
className="h-9 rounded-md border border-input bg-transparent px-2 text-sm"
|
|
689
|
+
>
|
|
690
|
+
{pageSizeOptions.map((n) => (
|
|
691
|
+
<option key={n} value={n}>
|
|
692
|
+
{n} {t("table.perPage")}
|
|
693
|
+
</option>
|
|
694
|
+
))}
|
|
695
|
+
</select>
|
|
696
|
+
</div>
|
|
662
697
|
</div>
|
|
663
|
-
|
|
698
|
+
)}
|
|
664
699
|
</Card>
|
|
665
700
|
|
|
666
701
|
{storageKey && (
|
|
@@ -6,12 +6,15 @@ import { useState } from "react";
|
|
|
6
6
|
|
|
7
7
|
import {
|
|
8
8
|
addMonths,
|
|
9
|
+
dayPlaceholder,
|
|
9
10
|
formatDay,
|
|
10
11
|
fromIsoDay,
|
|
11
12
|
isSameDay,
|
|
12
13
|
isSameMonth,
|
|
14
|
+
maskDay,
|
|
13
15
|
monthGrid,
|
|
14
16
|
monthLabel,
|
|
17
|
+
parseTypedDay,
|
|
15
18
|
today,
|
|
16
19
|
toIsoDay,
|
|
17
20
|
weekdayLabels,
|
|
@@ -19,6 +22,7 @@ import {
|
|
|
19
22
|
import { Button } from "#core/components/ui/button";
|
|
20
23
|
import {
|
|
21
24
|
Popover,
|
|
25
|
+
PopoverAnchor,
|
|
22
26
|
PopoverContent,
|
|
23
27
|
PopoverTrigger,
|
|
24
28
|
} from "#core/components/ui/popover";
|
|
@@ -46,6 +50,10 @@ export interface DatePickerProps {
|
|
|
46
50
|
* O valor continua sendo a string ISO do dia, e não `Date`: é o que o backend
|
|
47
51
|
* grava (`char(10)`) e o que evita a viagem por fuso que transforma dia 1º às
|
|
48
52
|
* 00:00 locais no dia 30 em UTC.
|
|
53
|
+
*
|
|
54
|
+
* O campo é **digitável**: `1108` já é 11 de agosto, e quem sabe a data não
|
|
55
|
+
* precisa navegar meses no calendário para dizê-la. O calendário continua ali,
|
|
56
|
+
* no ícone — é o caminho de quem procura o dia, não de quem já o conhece.
|
|
49
57
|
*/
|
|
50
58
|
export function DatePicker({
|
|
51
59
|
value,
|
|
@@ -56,15 +64,55 @@ export function DatePicker({
|
|
|
56
64
|
}: DatePickerProps): JSX.Element {
|
|
57
65
|
const { t, locale } = useI18n();
|
|
58
66
|
const [open, setOpen] = useState(false);
|
|
67
|
+
/*
|
|
68
|
+
O que está sendo digitado, enquanto ainda não é uma data.
|
|
69
|
+
|
|
70
|
+
Sem este rascunho o campo teria de apagar cada tecla que não fecha um dia
|
|
71
|
+
válido — digitar `1` já voltaria para o valor gravado, e não haveria como
|
|
72
|
+
chegar ao `11`.
|
|
73
|
+
*/
|
|
74
|
+
const [typed, setTyped] = useState<string | null>(null);
|
|
59
75
|
|
|
60
76
|
const selected = fromIsoDay(value);
|
|
61
77
|
const [month, setMonth] = useState<Date>(() => selected ?? today());
|
|
62
78
|
|
|
63
79
|
const pickDay = (day: Date): void => {
|
|
64
80
|
onChange(toIsoDay(day));
|
|
81
|
+
setTyped(null);
|
|
65
82
|
setOpen(false);
|
|
66
83
|
};
|
|
67
84
|
|
|
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) : ""));
|
|
96
|
+
setTyped(texto);
|
|
97
|
+
|
|
98
|
+
if (texto === "") {
|
|
99
|
+
onChange(undefined);
|
|
100
|
+
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const iso = parseTypedDay(
|
|
105
|
+
texto,
|
|
106
|
+
locale,
|
|
107
|
+
(selected ?? today()).getFullYear(),
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
if (iso) {
|
|
111
|
+
onChange(iso);
|
|
112
|
+
setMonth(fromIsoDay(iso) ?? month);
|
|
113
|
+
}
|
|
114
|
+
};
|
|
115
|
+
|
|
68
116
|
const dayClass = (day: Date): string => {
|
|
69
117
|
if (selected && isSameDay(day, selected)) {
|
|
70
118
|
return "bg-primary text-primary-foreground font-semibold";
|
|
@@ -77,16 +125,41 @@ export function DatePicker({
|
|
|
77
125
|
|
|
78
126
|
return (
|
|
79
127
|
<Popover open={open} onOpenChange={setOpen}>
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
>
|
|
85
|
-
<
|
|
86
|
-
{
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
128
|
+
{/*
|
|
129
|
+
A âncora é a linha inteira, e não o ícone: presa ao botão, o painel
|
|
130
|
+
abriria alinhado a ele e com a largura dele — um calendário de 16px.
|
|
131
|
+
*/}
|
|
132
|
+
<PopoverAnchor asChild>
|
|
133
|
+
<div
|
|
134
|
+
className={cn(
|
|
135
|
+
"flex h-10 w-full items-center rounded-md border border-input bg-transparent shadow-sm transition-colors hover:border-ring focus-within:ring-2 focus-within:ring-ring",
|
|
136
|
+
disabled && "cursor-not-allowed opacity-50",
|
|
137
|
+
)}
|
|
138
|
+
>
|
|
139
|
+
<input
|
|
140
|
+
aria-label={label}
|
|
141
|
+
value={typed ?? (value ? formatDay(value, locale) : "")}
|
|
142
|
+
disabled={disabled}
|
|
143
|
+
// Só dígitos, e o teclado do celular abre no numérico: a máscara é
|
|
144
|
+
// quem escreve as barras.
|
|
145
|
+
inputMode="numeric"
|
|
146
|
+
placeholder={placeholder ?? dayPlaceholder(locale)}
|
|
147
|
+
onChange={(event) => digitar(event.target.value)}
|
|
148
|
+
// Sai do campo e o rascunho some: `11/0` não é data, e deixá-lo
|
|
149
|
+
// escrito faria o campo mostrar uma coisa e o formulário guardar
|
|
150
|
+
// outra.
|
|
151
|
+
onBlur={() => setTyped(null)}
|
|
152
|
+
className="h-full w-full min-w-0 bg-transparent px-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed"
|
|
153
|
+
/>
|
|
154
|
+
<PopoverTrigger
|
|
155
|
+
disabled={disabled}
|
|
156
|
+
aria-label={t("filters.openCalendar")}
|
|
157
|
+
className="flex h-full shrink-0 items-center px-3 text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed"
|
|
158
|
+
>
|
|
159
|
+
<Calendar className="h-4 w-4" />
|
|
160
|
+
</PopoverTrigger>
|
|
161
|
+
</div>
|
|
162
|
+
</PopoverAnchor>
|
|
90
163
|
|
|
91
164
|
<PopoverContent
|
|
92
165
|
// Largura do campo, e não fixa: o painel é a continuação dele, e
|
|
@@ -160,6 +233,7 @@ export function DatePicker({
|
|
|
160
233
|
size="sm"
|
|
161
234
|
onClick={() => {
|
|
162
235
|
onChange(undefined);
|
|
236
|
+
setTyped(null);
|
|
163
237
|
setOpen(false);
|
|
164
238
|
}}
|
|
165
239
|
>
|
|
@@ -4,6 +4,7 @@ import * as DialogPrimitive from "@radix-ui/react-dialog";
|
|
|
4
4
|
import { Expand, Shrink, X } from "lucide-react";
|
|
5
5
|
import * as React from "react";
|
|
6
6
|
|
|
7
|
+
import { Button, type ButtonProps } from "#core/components/ui/button";
|
|
7
8
|
import {
|
|
8
9
|
EXPAND_SHORTCUT_KEY,
|
|
9
10
|
EXPAND_SHORTCUT_LABEL,
|
|
@@ -11,6 +12,25 @@ import {
|
|
|
11
12
|
} from "#core/hooks/use-shortcut";
|
|
12
13
|
import { cn } from "#core/lib/utils";
|
|
13
14
|
|
|
15
|
+
/**
|
|
16
|
+
* Como pedir para fechar o modal **passando pela guarda**.
|
|
17
|
+
*
|
|
18
|
+
* O `Esc`, o clique fora e o `X` passam pelo `DialogContent`, que pergunta
|
|
19
|
+
* antes de descartar o que foi preenchido. O botão "Cancelar" mora na tela e
|
|
20
|
+
* chamava o `onClose` dela direto — mesma saída, sem a pergunta. Este contexto
|
|
21
|
+
* é o caminho que faltava: quem pede daqui recebe a mesma confirmação.
|
|
22
|
+
*/
|
|
23
|
+
const DialogCloseRequestContext = React.createContext<() => void>(() => {});
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Pede o fechamento do modal em que o componente está.
|
|
27
|
+
*
|
|
28
|
+
* Fora de um `DialogContent` não faz nada — nunca quebra, só não tem o que
|
|
29
|
+
* fechar.
|
|
30
|
+
*/
|
|
31
|
+
const useDialogCloseRequest = (): (() => void) =>
|
|
32
|
+
React.useContext(DialogCloseRequestContext);
|
|
33
|
+
|
|
14
34
|
const Dialog = DialogPrimitive.Root;
|
|
15
35
|
const DialogTrigger = DialogPrimitive.Trigger;
|
|
16
36
|
const DialogPortal = DialogPrimitive.Portal;
|
|
@@ -248,6 +268,22 @@ const DialogContent = React.forwardRef<
|
|
|
248
268
|
setConfirmandoSaida(true);
|
|
249
269
|
};
|
|
250
270
|
|
|
271
|
+
/**
|
|
272
|
+
* A saída pedida de dentro do corpo — é o que o "Cancelar" usa.
|
|
273
|
+
*
|
|
274
|
+
* Sem evento para segurar: aqui o fechamento nem começa, ou é a pergunta que
|
|
275
|
+
* aparece, ou é o `Close` escondido que fecha de uma vez.
|
|
276
|
+
*/
|
|
277
|
+
const pedirSaida = React.useCallback((): void => {
|
|
278
|
+
if (unsavedChanges) {
|
|
279
|
+
setConfirmandoSaida(true);
|
|
280
|
+
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
fecharRef.current?.click();
|
|
285
|
+
}, [unsavedChanges]);
|
|
286
|
+
|
|
251
287
|
// O cabeçalho sai da lista para dividir a primeira linha com os botões; o
|
|
252
288
|
// resto segue na ordem em que o chamador escreveu.
|
|
253
289
|
const items = React.Children.toArray(children);
|
|
@@ -338,7 +374,9 @@ const DialogContent = React.forwardRef<
|
|
|
338
374
|
</DialogPrimitive.Close>
|
|
339
375
|
</div>
|
|
340
376
|
</div>
|
|
341
|
-
{
|
|
377
|
+
<DialogCloseRequestContext.Provider value={pedirSaida}>
|
|
378
|
+
{body}
|
|
379
|
+
</DialogCloseRequestContext.Provider>
|
|
342
380
|
|
|
343
381
|
<DialogPrimitive.Close ref={fecharRef} className="hidden" tabIndex={-1} />
|
|
344
382
|
</DialogPrimitive.Content>
|
|
@@ -467,8 +505,49 @@ const DialogDescription = React.forwardRef<
|
|
|
467
505
|
));
|
|
468
506
|
DialogDescription.displayName = DialogPrimitive.Description.displayName;
|
|
469
507
|
|
|
508
|
+
/**
|
|
509
|
+
* O "Cancelar" do rodapé — a mesma saída do `Esc`, com a mesma pergunta.
|
|
510
|
+
*
|
|
511
|
+
* Existe porque o botão que a tela escrevia à mão chamava o `onClose` dela
|
|
512
|
+
* direto: fechava sem perguntar nada, e o formulário preenchido ia junto.
|
|
513
|
+
* Aqui o fechamento é pedido ao modal, que decide se pergunta antes.
|
|
514
|
+
*
|
|
515
|
+
* O texto vem de fora — este arquivo não depende do provider de i18n, e passar
|
|
516
|
+
* a depender quebraria todo uso fora dele.
|
|
517
|
+
*
|
|
518
|
+
* ```tsx
|
|
519
|
+
* <DialogFooter>
|
|
520
|
+
* <DialogCancel>{t("common.cancel")}</DialogCancel>
|
|
521
|
+
* <Button type="submit">{t("common.save")}</Button>
|
|
522
|
+
* </DialogFooter>
|
|
523
|
+
* ```
|
|
524
|
+
*/
|
|
525
|
+
const DialogCancel = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
|
526
|
+
({ variant = "outline", type = "button", onClick, ...props }, ref) => {
|
|
527
|
+
const pedirSaida = useDialogCloseRequest();
|
|
528
|
+
|
|
529
|
+
return (
|
|
530
|
+
<Button
|
|
531
|
+
ref={ref}
|
|
532
|
+
type={type}
|
|
533
|
+
variant={variant}
|
|
534
|
+
onClick={(event) => {
|
|
535
|
+
onClick?.(event);
|
|
536
|
+
|
|
537
|
+
if (!event.defaultPrevented) {
|
|
538
|
+
pedirSaida();
|
|
539
|
+
}
|
|
540
|
+
}}
|
|
541
|
+
{...props}
|
|
542
|
+
/>
|
|
543
|
+
);
|
|
544
|
+
},
|
|
545
|
+
);
|
|
546
|
+
DialogCancel.displayName = "DialogCancel";
|
|
547
|
+
|
|
470
548
|
export {
|
|
471
549
|
Dialog,
|
|
550
|
+
DialogCancel,
|
|
472
551
|
DialogClose,
|
|
473
552
|
DialogContent,
|
|
474
553
|
DialogDescription,
|
|
@@ -479,4 +558,5 @@ export {
|
|
|
479
558
|
DialogPortal,
|
|
480
559
|
DialogTitle,
|
|
481
560
|
DialogTrigger,
|
|
561
|
+
useDialogCloseRequest,
|
|
482
562
|
};
|
package/src/i18n/messages/en.ts
CHANGED
package/src/i18n/messages/pt.ts
CHANGED