rl-core-front 0.10.1 → 0.11.0

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.10.1",
3
+ "version": "0.11.0",
4
4
  "description": "Telas e componentes Next.js do core: login com 2FA, usu\u00e1rios, RBAC, auditoria, logs e listagens com filtro din\u00e2mico",
5
5
  "author": "Rodrigo Liberti",
6
6
  "license": "MIT",
@@ -9,10 +9,12 @@ import { Button } from "#core/components/ui/button";
9
9
  import {
10
10
  DropdownMenu,
11
11
  DropdownMenuContent,
12
+ DropdownMenuItem,
12
13
  DropdownMenuTrigger,
13
14
  } from "#core/components/ui/dropdown-menu";
14
15
  import { Input } from "#core/components/ui/input";
15
16
  import { useI18n } from "#core/contexts";
17
+ import { cn } from "#core/lib/utils";
16
18
 
17
19
  /** O mínimo para desenhar uma opção: o que identifica e o que se lê. */
18
20
  export interface ComboboxOption {
@@ -60,6 +62,7 @@ export function Combobox({
60
62
  const [open, setOpen] = useState(false);
61
63
  const [search, setSearch] = useState("");
62
64
  const inputRef = useRef<HTMLInputElement>(null);
65
+ const painelRef = useRef<HTMLDivElement>(null);
63
66
 
64
67
  const selected = useMemo(
65
68
  () => options.filter((option) => value.includes(option.id)),
@@ -157,18 +160,25 @@ export function Combobox({
157
160
  </DropdownMenuTrigger>
158
161
 
159
162
  <DropdownMenuContent
163
+ ref={painelRef}
160
164
  /*
161
- * Cara de select: mesma largura do campo e aberto **por cima** dele.
165
+ * Largura do campo sempre; sobreposto quando é de um valor só.
162
166
  *
163
167
  * A largura precisa de `var(...)` por extenso — o atalho
164
- * `w-[--variavel]` era do Tailwind 3 e não gera CSS na 4. A margem
165
- * negativa é o que sobrepõe: com `sideOffset` zero o painel encosta na
166
- * borda do campo, e subir a altura dele cobre o campo. O `data-[side]`
167
- * cuida da abertura para cima, quando não espaço embaixo.
168
+ * `w-[--variavel]` era do Tailwind 3 e não gera CSS na 4.
169
+ *
170
+ * Escolhendo um valor, cobrir o campo cara de select e o painel
171
+ * fecha em seguida. No modo múltiplo o campo é onde ficam os itens já
172
+ * escolhidos: cobri-lo esconderia justamente o que se acabou de marcar,
173
+ * e o painel continua aberto para marcar o próximo.
168
174
  */
169
- className="max-h-72 w-(--radix-dropdown-menu-trigger-width) overflow-y-auto p-1 data-[side=bottom]:-mt-(--radix-dropdown-menu-trigger-height) data-[side=top]:-mb-(--radix-dropdown-menu-trigger-height)"
175
+ className={cn(
176
+ "max-h-72 w-(--radix-dropdown-menu-trigger-width) overflow-y-auto p-1",
177
+ !multiple &&
178
+ "data-[side=bottom]:-mt-(--radix-dropdown-menu-trigger-height) data-[side=top]:-mb-(--radix-dropdown-menu-trigger-height)",
179
+ )}
170
180
  align="start"
171
- sideOffset={0}
181
+ sideOffset={multiple ? 4 : 0}
172
182
  >
173
183
  <div className="p-1">
174
184
  <Input
@@ -176,14 +186,46 @@ export function Combobox({
176
186
  value={search}
177
187
  placeholder={searchPlaceholder ?? t("common.search")}
178
188
  onChange={(event) => setSearch(event.target.value)}
179
- // O menu do Radix tem busca por digitação própria: sem parar o
180
- // evento aqui, digitar "c" pularia o foco para o item "Chase" em
181
- // vez de escrever no campo.
189
+ /*
190
+ * o que é texto fica preso no campo.
191
+ *
192
+ * O menu do Radix tem busca por digitação própria: sem parar essas
193
+ * teclas, digitar "c" pularia o foco para o item "Chase" em vez de
194
+ * escrever. Mas parar **tudo** — como estava — leva junto as setas,
195
+ * e aí não há como percorrer a lista pelo teclado.
196
+ */
182
197
  onKeyDown={(event) => {
183
- event.stopPropagation();
198
+ if (event.key.length === 1) {
199
+ event.stopPropagation();
200
+ }
201
+
202
+ /*
203
+ * O primeiro salto do campo para a lista é por conta da gente.
204
+ *
205
+ * O Radix navega entre os itens com as setas, mas só depois que o
206
+ * foco já está num deles: a checagem dele é
207
+ * `if (event.target !== content) return`, e daqui o alvo é o
208
+ * campo de busca. Sem este empurrão, a seta não faz nada — e foi
209
+ * por isso que declarar os itens como `DropdownMenuItem` não
210
+ * bastou.
211
+ */
212
+ if (event.key === "ArrowDown" || event.key === "ArrowUp") {
213
+ const itens = painelRef.current?.querySelectorAll<HTMLElement>(
214
+ '[role="menuitem"]',
215
+ );
216
+ if (itens?.length) {
217
+ event.preventDefault();
218
+ const alvo =
219
+ event.key === "ArrowDown" ? itens[0] : itens[itens.length - 1];
220
+ alvo.focus();
221
+ }
222
+ return;
223
+ }
224
+
184
225
  if (event.key !== "Enter") {
185
226
  return;
186
227
  }
228
+ event.stopPropagation();
187
229
  // Enter escolhe o que a busca deixou na tela — sem isto, filtrar
188
230
  // pelo teclado ainda terminaria no mouse.
189
231
  event.preventDefault();
@@ -195,15 +237,20 @@ export function Combobox({
195
237
  </div>
196
238
 
197
239
  {visiveis.map((option) => (
198
- <button
240
+ <DropdownMenuItem
199
241
  key={option.id}
200
- type="button"
201
- className="flex w-full items-center justify-between rounded-sm px-2 py-1.5 text-sm hover:bg-accent"
202
- onClick={() => alternar(option.id)}
242
+ className="flex w-full items-center justify-between"
243
+ // Escolher **não** fecha por conta do Radix: no modo múltiplo,
244
+ // fechar a cada marcação obrigaria a reabrir para o próximo. Quem
245
+ // fecha é o `alternar`, e só quando é de um valor só.
246
+ onSelect={(event) => {
247
+ event.preventDefault();
248
+ alternar(option.id);
249
+ }}
203
250
  >
204
251
  <span className="truncate">{option.name}</span>
205
252
  {value.includes(option.id) && <Check className="h-4 w-4" />}
206
- </button>
253
+ </DropdownMenuItem>
207
254
  ))}
208
255
 
209
256
  {visiveis.length === 0 && (
@@ -148,16 +148,80 @@ export interface DialogContentProps
148
148
  * curta, por exemplo, que esticada só fica com mais vazio.
149
149
  */
150
150
  expandable?: boolean;
151
+ /**
152
+ * Há edição não salva: fechar passa a perguntar antes.
153
+ *
154
+ * Vale para as três saídas — `Esc`, clique fora e o `X` —, porque são todas a
155
+ * mesma tecla errada. Quem sabe se há edição é o formulário (com
156
+ * `react-hook-form`, `formState.isDirty`), então a resposta vem de fora.
157
+ */
158
+ unsavedChanges?: boolean;
159
+ /**
160
+ * Textos da pergunta, para quem traduz.
161
+ *
162
+ * Por prop e não pelo i18n porque este componente não depende do provider —
163
+ * e passar a depender quebraria todo uso fora dele, inclusive nos testes.
164
+ */
165
+ unsavedTexts?: {
166
+ title?: string;
167
+ description?: string;
168
+ confirmLabel?: string;
169
+ cancelLabel?: string;
170
+ };
151
171
  }
152
172
 
173
+ /** O que a pergunta diz quando ninguém traduz. */
174
+ const UNSAVED_PADRAO = {
175
+ title: "Descartar as alterações?",
176
+ description:
177
+ "O que você preencheu ainda não foi salvo e será perdido ao fechar.",
178
+ confirmLabel: "Descartar",
179
+ cancelLabel: "Continuar editando",
180
+ } as const;
181
+
153
182
  const DialogContent = React.forwardRef<
154
183
  React.ElementRef<typeof DialogPrimitive.Content>,
155
184
  DialogContentProps
156
- >(({ className, children, expandable = true, onOpenAutoFocus, ...props }, ref) => {
185
+ >(
186
+ (
187
+ {
188
+ className,
189
+ children,
190
+ expandable = true,
191
+ onOpenAutoFocus,
192
+ onEscapeKeyDown,
193
+ onPointerDownOutside,
194
+ unsavedChanges = false,
195
+ unsavedTexts,
196
+ ...props
197
+ },
198
+ ref,
199
+ ) => {
157
200
  // Reinicia a cada montagem: o Radix desmonta o conteúdo ao fechar, então
158
201
  // reabrir traz o modal no tamanho normal — modal pequeno que volta ampliado
159
202
  // surpreende quem o abriu.
160
203
  const [expanded, setExpanded] = React.useState(false);
204
+ const [confirmandoSaida, setConfirmandoSaida] = React.useState(false);
205
+
206
+ /**
207
+ * Fecha de verdade, depois de confirmado.
208
+ *
209
+ * O `open` do modal é de quem o abriu, e daqui não há como mexer nele — este
210
+ * `Close` escondido é o que empresta o fechamento do Radix sem pedir uma prop
211
+ * nova a todas as telas que já usam o componente.
212
+ */
213
+ const fecharRef = React.useRef<HTMLButtonElement>(null);
214
+
215
+ const textos = { ...UNSAVED_PADRAO, ...unsavedTexts };
216
+
217
+ /** Segura a saída e pergunta, quando há o que perder. */
218
+ const interceptar = (event: { preventDefault: () => void }): void => {
219
+ if (!unsavedChanges) {
220
+ return;
221
+ }
222
+ event.preventDefault();
223
+ setConfirmandoSaida(true);
224
+ };
161
225
 
162
226
  // O cabeçalho sai da lista para dividir a primeira linha com os botões; o
163
227
  // resto segue na ordem em que o chamador escreveu.
@@ -202,6 +266,18 @@ const DialogContent = React.forwardRef<
202
266
  primeiroCampo.focus();
203
267
  }
204
268
  }}
269
+ onEscapeKeyDown={(event) => {
270
+ onEscapeKeyDown?.(event);
271
+ if (!event.defaultPrevented) {
272
+ interceptar(event);
273
+ }
274
+ }}
275
+ onPointerDownOutside={(event) => {
276
+ onPointerDownOutside?.(event);
277
+ if (!event.defaultPrevented) {
278
+ interceptar(event);
279
+ }
280
+ }}
205
281
  {...props}
206
282
  >
207
283
  {/* Título e botões na mesma linha de um flex, em vez de os botões
@@ -227,17 +303,65 @@ const DialogContent = React.forwardRef<
227
303
  </span>
228
304
  </button>
229
305
  )}
230
- <DialogPrimitive.Close className="rounded-sm opacity-70 transition-opacity hover:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
306
+ <DialogPrimitive.Close
307
+ onClick={interceptar}
308
+ className="rounded-sm opacity-70 transition-opacity hover:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
309
+ >
231
310
  <X className="h-4 w-4" />
232
311
  <span className="sr-only">Fechar</span>
233
312
  </DialogPrimitive.Close>
234
313
  </div>
235
314
  </div>
236
315
  {body}
316
+
317
+ <DialogPrimitive.Close ref={fecharRef} className="hidden" tabIndex={-1} />
237
318
  </DialogPrimitive.Content>
319
+
320
+ {/*
321
+ A pergunta é montada aqui, com os primitivos do próprio arquivo, e não
322
+ com o `ConfirmDialog`: é ele que importa daqui, e usá-lo fecharia o
323
+ ciclo entre os dois módulos.
324
+ */}
325
+ <DialogPrimitive.Root
326
+ open={confirmandoSaida}
327
+ onOpenChange={(aberto) => !aberto && setConfirmandoSaida(false)}
328
+ >
329
+ <DialogPortal>
330
+ <DialogOverlay />
331
+ <DialogPrimitive.Content className="fixed left-[50%] top-[50%] z-50 flex w-[calc(100%-2rem)] max-w-sm translate-x-[-50%] translate-y-[-50%] flex-col gap-4 rounded-xl border border-border bg-card p-6 shadow-lg">
332
+ <DialogPrimitive.Title className="text-lg font-semibold">
333
+ {textos.title}
334
+ </DialogPrimitive.Title>
335
+ <DialogPrimitive.Description className="text-sm text-muted-foreground">
336
+ {textos.description}
337
+ </DialogPrimitive.Description>
338
+ <div className="-mb-2 flex flex-col-reverse gap-2 border-t border-border pt-4 sm:flex-row sm:justify-end">
339
+ <button
340
+ type="button"
341
+ onClick={() => setConfirmandoSaida(false)}
342
+ className="inline-flex h-10 items-center justify-center rounded-md border border-input px-4 py-2 text-sm font-medium transition-colors hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
343
+ >
344
+ {textos.cancelLabel}
345
+ </button>
346
+ <button
347
+ type="button"
348
+ autoFocus
349
+ onClick={() => {
350
+ setConfirmandoSaida(false);
351
+ fecharRef.current?.click();
352
+ }}
353
+ className="inline-flex h-10 items-center justify-center rounded-md bg-destructive px-4 py-2 text-sm font-medium text-destructive-foreground transition-colors hover:bg-destructive/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
354
+ >
355
+ {textos.confirmLabel}
356
+ </button>
357
+ </div>
358
+ </DialogPrimitive.Content>
359
+ </DialogPortal>
360
+ </DialogPrimitive.Root>
238
361
  </DialogPortal>
239
362
  );
240
- });
363
+ },
364
+ );
241
365
  DialogContent.displayName = DialogPrimitive.Content.displayName;
242
366
 
243
367
  /**