rl-core-front 0.15.4 → 0.15.6

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.
@@ -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
- <PopoverTrigger
81
- disabled={disabled}
82
- aria-label={label}
83
- className="flex h-10 w-full items-center justify-between gap-2 rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm transition-colors hover:border-ring focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
84
- >
85
- <span className={cn("truncate", !value && "text-muted-foreground")}>
86
- {value ? formatDay(value, locale) : (placeholder ?? t("filters.selectDate"))}
87
- </span>
88
- <Calendar className="h-4 w-4 shrink-0 text-muted-foreground" />
89
- </PopoverTrigger>
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
- {body}
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
  };
@@ -36,6 +36,27 @@ const TableBody = React.forwardRef<
36
36
  ));
37
37
  TableBody.displayName = "TableBody";
38
38
 
39
+ /**
40
+ * A linha de fechamento da tabela: total, saldo, contagem.
41
+ *
42
+ * `tfoot` e não uma última linha do corpo: ela não é registro — não ordena
43
+ * junto, não entra na contagem e continua embaixo quando a ordenação muda.
44
+ */
45
+ const TableFooter = React.forwardRef<
46
+ HTMLTableSectionElement,
47
+ React.HTMLAttributes<HTMLTableSectionElement>
48
+ >(({ className, ...props }, ref) => (
49
+ <tfoot
50
+ ref={ref}
51
+ className={cn(
52
+ "border-t border-border bg-muted/40 font-medium [&_tr]:border-0",
53
+ className,
54
+ )}
55
+ {...props}
56
+ />
57
+ ));
58
+ TableFooter.displayName = "TableFooter";
59
+
39
60
  const TableRow = React.forwardRef<
40
61
  HTMLTableRowElement,
41
62
  React.HTMLAttributes<HTMLTableRowElement>
@@ -90,4 +111,12 @@ const TableCell = React.forwardRef<
90
111
  ));
91
112
  TableCell.displayName = "TableCell";
92
113
 
93
- export { Table, TableBody, TableCell,TableHead, TableHeader, TableRow };
114
+ export {
115
+ Table,
116
+ TableBody,
117
+ TableCell,
118
+ TableFooter,
119
+ TableHead,
120
+ TableHeader,
121
+ TableRow,
122
+ };
@@ -15,11 +15,16 @@ export interface UseColumnVisibilityResult {
15
15
  /**
16
16
  * Persiste as colunas ocultas de uma tabela em localStorage (por storageKey).
17
17
  * Retorna a lista de ocultas + helpers para ler/salvar.
18
+ *
19
+ * `defaultHidden` é só o ponto de partida: vale enquanto ninguém tiver
20
+ * escolhido as colunas nesta tela. Depois da primeira escolha, a lista guardada
21
+ * é a verdade — inclusive quando ela mostra uma coluna que nasceu escondida.
18
22
  */
19
23
  export function useColumnVisibility(
20
24
  storageKey?: string,
25
+ defaultHidden: string[] = [],
21
26
  ): UseColumnVisibilityResult {
22
- const [hiddenColumns, setHiddenColumns] = useState<string[]>([]);
27
+ const [hiddenColumns, setHiddenColumns] = useState<string[]>(defaultHidden);
23
28
 
24
29
  useEffect(() => {
25
30
  if (!storageKey) {return;}
@@ -0,0 +1,54 @@
1
+ "use client";
2
+
3
+ import { useCallback, useEffect, useState } from "react";
4
+
5
+ import { safeStorage } from "#core/_utils/storage";
6
+
7
+ const STORAGE_PREFIX = "view:";
8
+
9
+ /** Como a listagem se apresenta: em linhas ou em cartões. */
10
+ export type TableView = "list" | "grid";
11
+
12
+ export interface UseTableViewResult {
13
+ view: TableView;
14
+ setView: (next: TableView) => void;
15
+ }
16
+
17
+ /**
18
+ * Guarda em qual formato a pessoa deixou a listagem, por `storageKey`.
19
+ *
20
+ * Mesma ideia das colunas escondidas: é escolha de quem olha, não da tela, e
21
+ * refazê-la a cada visita transformaria o alternador num incômodo. Sem
22
+ * `storageKey` o formato dura só enquanto a tela estiver aberta — igual às
23
+ * colunas, que também só persistem quando a tabela tem chave.
24
+ */
25
+ export function useTableView(
26
+ storageKey?: string,
27
+ defaultView: TableView = "list",
28
+ ): UseTableViewResult {
29
+ const [view, setStoredView] = useState<TableView>(defaultView);
30
+
31
+ useEffect(() => {
32
+ if (!storageKey) {
33
+ return;
34
+ }
35
+ const raw = safeStorage.get(`${STORAGE_PREFIX}${storageKey}`);
36
+
37
+ if (raw === "list" || raw === "grid") {
38
+ setStoredView(raw);
39
+ }
40
+ }, [storageKey]);
41
+
42
+ const setView = useCallback(
43
+ (next: TableView) => {
44
+ setStoredView(next);
45
+
46
+ if (storageKey) {
47
+ safeStorage.set(`${STORAGE_PREFIX}${storageKey}`, next);
48
+ }
49
+ },
50
+ [storageKey],
51
+ );
52
+
53
+ return { view, setView };
54
+ }
@@ -165,8 +165,12 @@ export const en: Messages = {
165
165
  nextPage: "Next page",
166
166
  empty: "No records",
167
167
  emptyFiltered: "No records match this filter",
168
+ view: "List format",
169
+ viewList: "List",
170
+ viewGrid: "Cards",
168
171
  reorder: "Drag to reorder",
169
172
  reorderSorted: "Clear the column sorting to drag rows",
173
+ reorderGrouped: "Rows cannot be reordered while the list is grouped",
170
174
  },
171
175
  filters: {
172
176
  button: "Filters",
@@ -183,6 +187,7 @@ export const en: Messages = {
183
187
  toChip: "until",
184
188
  selectPeriod: "Select a period",
185
189
  selectDate: "Select a date",
190
+ openCalendar: "Open calendar",
186
191
  today: "Today",
187
192
  lastDays: "{days} days",
188
193
  previousMonth: "Previous month",
@@ -167,9 +167,13 @@ export const pt = {
167
167
  nextPage: "Próxima página",
168
168
  empty: "Nenhum registro",
169
169
  emptyFiltered: "Nenhum registro para este filtro",
170
+ view: "Formato da listagem",
171
+ viewList: "Lista",
172
+ viewGrid: "Cartões",
170
173
  reorder: "Arraste para reordenar",
171
174
  reorderSorted:
172
175
  "Limpe a ordenação por coluna para arrastar as linhas",
176
+ reorderGrouped: "Não dá para reordenar com a lista dividida em seções",
173
177
  },
174
178
  filters: {
175
179
  button: "Filtros",
@@ -186,6 +190,7 @@ export const pt = {
186
190
  toChip: "até",
187
191
  selectPeriod: "Selecione o período",
188
192
  selectDate: "Selecione a data",
193
+ openCalendar: "Abrir calendário",
189
194
  today: "Hoje",
190
195
  lastDays: "{days} dias",
191
196
  previousMonth: "Mês anterior",
package/src/index.ts CHANGED
@@ -110,6 +110,8 @@ export {
110
110
  EXPAND_SHORTCUT_LABEL,
111
111
  useShortcut,
112
112
  } from "#core/hooks/use-shortcut";
113
+ export type { TableView, UseTableViewResult } from "#core/hooks/use-table-view";
114
+ export { useTableView } from "#core/hooks/use-table-view";
113
115
  // RequestOperation é enum (valor, não só tipo): sem ele o projeto não consegue
114
116
  // pedir o toast padrão de criado/alterado/removido que o core já traduz.
115
117
  export { RequestOperation, useRequest } from "#core/hooks/use-request";