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 +1 -1
- package/src/components/ui/combobox.tsx +63 -16
- package/src/components/ui/dialog.tsx +127 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "rl-core-front",
|
|
3
|
-
"version": "0.
|
|
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
|
-
*
|
|
165
|
+
* Largura do campo sempre; sobreposto só 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.
|
|
165
|
-
*
|
|
166
|
-
*
|
|
167
|
-
*
|
|
168
|
+
* `w-[--variavel]` era do Tailwind 3 e não gera CSS na 4.
|
|
169
|
+
*
|
|
170
|
+
* Escolhendo um valor, cobrir o campo dá 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=
|
|
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
|
-
|
|
180
|
-
|
|
181
|
-
|
|
189
|
+
/*
|
|
190
|
+
* Só 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.
|
|
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
|
-
<
|
|
240
|
+
<DropdownMenuItem
|
|
199
241
|
key={option.id}
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
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
|
-
</
|
|
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
|
-
>(
|
|
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
|
/**
|