rl-core-front 0.10.2 → 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.2",
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",
@@ -62,6 +62,7 @@ export function Combobox({
62
62
  const [open, setOpen] = useState(false);
63
63
  const [search, setSearch] = useState("");
64
64
  const inputRef = useRef<HTMLInputElement>(null);
65
+ const painelRef = useRef<HTMLDivElement>(null);
65
66
 
66
67
  const selected = useMemo(
67
68
  () => options.filter((option) => value.includes(option.id)),
@@ -159,6 +160,7 @@ export function Combobox({
159
160
  </DropdownMenuTrigger>
160
161
 
161
162
  <DropdownMenuContent
163
+ ref={painelRef}
162
164
  /*
163
165
  * Largura do campo sempre; sobreposto só quando é de um valor só.
164
166
  *
@@ -196,6 +198,30 @@ export function Combobox({
196
198
  if (event.key.length === 1) {
197
199
  event.stopPropagation();
198
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
+
199
225
  if (event.key !== "Enter") {
200
226
  return;
201
227
  }
@@ -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
  /**