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.
|
|
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
|
-
<
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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
|
-
>(
|
|
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
|
|
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
|
/**
|
package/src/i18n/messages/en.ts
CHANGED
package/src/i18n/messages/pt.ts
CHANGED