rl-core-front 0.10.2 → 0.11.1

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.1",
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",
@@ -28,6 +28,13 @@ export interface ComboboxProps {
28
28
  value: string[];
29
29
  onChange: (ids: string[]) => void;
30
30
  multiple?: boolean;
31
+ /**
32
+ * Mostra o X que devolve o campo ao vazio.
33
+ *
34
+ * Só faz sentido onde o vazio é resposta válida: num campo obrigatório o X
35
+ * seria um atalho para o erro de validação. Por isso não vem ligado.
36
+ */
37
+ clearable?: boolean;
31
38
  placeholder?: string;
32
39
  searchPlaceholder?: string;
33
40
  emptyMessage?: string;
@@ -53,6 +60,7 @@ export function Combobox({
53
60
  value,
54
61
  onChange,
55
62
  multiple = false,
63
+ clearable = false,
56
64
  placeholder,
57
65
  searchPlaceholder,
58
66
  emptyMessage,
@@ -62,6 +70,7 @@ export function Combobox({
62
70
  const [open, setOpen] = useState(false);
63
71
  const [search, setSearch] = useState("");
64
72
  const inputRef = useRef<HTMLInputElement>(null);
73
+ const painelRef = useRef<HTMLDivElement>(null);
65
74
 
66
75
  const selected = useMemo(
67
76
  () => options.filter((option) => value.includes(option.id)),
@@ -140,25 +149,57 @@ export function Combobox({
140
149
  ? selected.map((option) => (
141
150
  <Badge key={option.id} variant="secondary" className="gap-1">
142
151
  {option.name}
143
- <X
144
- className="h-3 w-3 cursor-pointer"
145
- onClick={(event) => {
146
- // Sem isto, tirar um selo abriria o menu junto.
152
+ <span
153
+ aria-label={`${t("common.remove")} ${option.name}`}
154
+ className="cursor-pointer"
155
+ /*
156
+ * Tirar um item é no `pointerdown`, não no `click`.
157
+ *
158
+ * O gatilho do Radix abre no `pointerdown`, e o menu
159
+ * aberto tira o `pointer-events` de tudo que está fora do
160
+ * painel — inclusive deste X, que mora no gatilho. O
161
+ * `click` chegava tarde: quando ele viria, o alvo já não
162
+ * recebia evento, e o item ficava marcado para sempre.
163
+ *
164
+ * `stopPropagation` no lugar do `onClick` de antes: aqui
165
+ * ele chega a tempo de o gatilho não ver o evento, e o
166
+ * menu não abre junto.
167
+ */
168
+ onPointerDown={(event) => {
169
+ event.preventDefault();
147
170
  event.stopPropagation();
148
171
  onChange(value.filter((id) => id !== option.id));
149
172
  }}
150
- />
173
+ >
174
+ <X className="h-3 w-3" />
175
+ </span>
151
176
  </Badge>
152
177
  ))
153
178
  : selected.map((option) => (
154
179
  <span key={option.id}>{option.name}</span>
155
180
  ))}
156
181
  </span>
182
+ {clearable && selected.length > 0 && !disabled && (
183
+ <span
184
+ aria-label={t("common.clear")}
185
+ className="shrink-0 opacity-60 hover:opacity-100"
186
+ // Mesmo motivo do X do chip: no `click` o gatilho já teria aberto
187
+ // o menu e tirado o ponteiro de quem está fora do painel.
188
+ onPointerDown={(event) => {
189
+ event.preventDefault();
190
+ event.stopPropagation();
191
+ onChange([]);
192
+ }}
193
+ >
194
+ <X className="h-4 w-4" />
195
+ </span>
196
+ )}
157
197
  <ChevronDown className="h-4 w-4 shrink-0 opacity-60" />
158
198
  </Button>
159
199
  </DropdownMenuTrigger>
160
200
 
161
201
  <DropdownMenuContent
202
+ ref={painelRef}
162
203
  /*
163
204
  * Largura do campo sempre; sobreposto só quando é de um valor só.
164
205
  *
@@ -196,6 +237,30 @@ export function Combobox({
196
237
  if (event.key.length === 1) {
197
238
  event.stopPropagation();
198
239
  }
240
+
241
+ /*
242
+ * O primeiro salto do campo para a lista é por conta da gente.
243
+ *
244
+ * O Radix navega entre os itens com as setas, mas só depois que o
245
+ * foco já está num deles: a checagem dele é
246
+ * `if (event.target !== content) return`, e daqui o alvo é o
247
+ * campo de busca. Sem este empurrão, a seta não faz nada — e foi
248
+ * por isso que declarar os itens como `DropdownMenuItem` não
249
+ * bastou.
250
+ */
251
+ if (event.key === "ArrowDown" || event.key === "ArrowUp") {
252
+ const itens = painelRef.current?.querySelectorAll<HTMLElement>(
253
+ '[role="menuitem"]',
254
+ );
255
+ if (itens?.length) {
256
+ event.preventDefault();
257
+ const alvo =
258
+ event.key === "ArrowDown" ? itens[0] : itens[itens.length - 1];
259
+ alvo.focus();
260
+ }
261
+ return;
262
+ }
263
+
199
264
  if (event.key !== "Enter") {
200
265
  return;
201
266
  }
@@ -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
  /**
@@ -200,6 +200,8 @@ export const en: Messages = {
200
200
  select: "Select…",
201
201
  search: "Search…",
202
202
  noResults: "Nothing found",
203
+ remove: "Remove",
204
+ clear: "Clear",
203
205
  no: "No",
204
206
  actions: "Actions",
205
207
  loading: "Loading...",
@@ -202,6 +202,8 @@ export const pt = {
202
202
  select: "Selecionar…",
203
203
  search: "Pesquisar…",
204
204
  noResults: "Nada encontrado",
205
+ remove: "Remover",
206
+ clear: "Limpar",
205
207
  no: "Não",
206
208
  actions: "Ações",
207
209
  loading: "Carregando...",