rl-core-front 0.16.5 → 0.16.7
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
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { Check, ChevronDown, X } from "lucide-react";
|
|
4
4
|
import type { JSX } from "react";
|
|
5
|
-
import { useEffect, useMemo, useRef, useState } from "react";
|
|
5
|
+
import { useEffect, useId, useMemo, useRef, useState } from "react";
|
|
6
6
|
|
|
7
7
|
import { Badge } from "#core/components/ui/badge";
|
|
8
8
|
import { Button } from "#core/components/ui/button";
|
|
@@ -54,6 +54,11 @@ export interface ComboboxProps {
|
|
|
54
54
|
*
|
|
55
55
|
* Só escolhe — não cria. Quem cadastra o item nem sempre é quem pode cadastrar
|
|
56
56
|
* na origem, e um "criar" aqui daria um botão que falha na cara de quem clicou.
|
|
57
|
+
*
|
|
58
|
+
* **O foco nunca sai do campo de busca.** Quem aponta o item é o `ativo`, e não
|
|
59
|
+
* o foco do navegador: dentro do menu do Radix, foco em item entrega as teclas
|
|
60
|
+
* seguintes à busca por digitação dele — a letra salta para outra opção em vez
|
|
61
|
+
* de entrar na busca.
|
|
57
62
|
*/
|
|
58
63
|
export function Combobox({
|
|
59
64
|
options,
|
|
@@ -69,8 +74,23 @@ export function Combobox({
|
|
|
69
74
|
const { t } = useI18n();
|
|
70
75
|
const [open, setOpen] = useState(false);
|
|
71
76
|
const [search, setSearch] = useState("");
|
|
77
|
+
/**
|
|
78
|
+
* O item que o teclado aponta. Nulo é "o primeiro que a busca deixou".
|
|
79
|
+
*
|
|
80
|
+
* Guardar o id, e não a posição, é o que faz o apontado sobreviver à
|
|
81
|
+
* digitação: enquanto ele continuar na lista filtrada segue apontado, e sai
|
|
82
|
+
* do topo só quando a busca o exclui.
|
|
83
|
+
*/
|
|
84
|
+
const [apontado, setApontado] = useState<string | null>(null);
|
|
72
85
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
73
|
-
const
|
|
86
|
+
const itensRef = useRef<Record<string, HTMLElement | null>>({});
|
|
87
|
+
/*
|
|
88
|
+
* O `open` do estado não serve dentro do `onKeyDown` do gatilho: a tecla
|
|
89
|
+
* seguinte pode chegar antes de a render nova existir, e ali a closure ainda
|
|
90
|
+
* enxerga o painel fechado.
|
|
91
|
+
*/
|
|
92
|
+
const abertoRef = useRef(false);
|
|
93
|
+
const idLista = useId();
|
|
74
94
|
|
|
75
95
|
const selected = useMemo(
|
|
76
96
|
() => options.filter((option) => value.includes(option.id)),
|
|
@@ -86,6 +106,12 @@ export function Combobox({
|
|
|
86
106
|
[options, termo],
|
|
87
107
|
);
|
|
88
108
|
|
|
109
|
+
/** A posição do apontado; sem ele — ou fora da busca — é a do primeiro. */
|
|
110
|
+
const ativo = Math.max(
|
|
111
|
+
visiveis.findIndex((option) => option.id === apontado),
|
|
112
|
+
0,
|
|
113
|
+
);
|
|
114
|
+
|
|
89
115
|
const alternar = (id: string): void => {
|
|
90
116
|
if (!multiple) {
|
|
91
117
|
onChange([id]);
|
|
@@ -101,11 +127,14 @@ export function Combobox({
|
|
|
101
127
|
);
|
|
102
128
|
};
|
|
103
129
|
|
|
130
|
+
useEffect(() => {
|
|
131
|
+
abertoRef.current = open;
|
|
132
|
+
}, [open]);
|
|
133
|
+
|
|
104
134
|
// O Radix leva o foco para o menu ao abrir, e isso vence o `autoFocus` do
|
|
105
135
|
// campo de busca — sem devolver o foco, só depois de clicar nele a digitação
|
|
106
|
-
// pesquisa.
|
|
107
|
-
//
|
|
108
|
-
// foco do Radix já agiu.
|
|
136
|
+
// pesquisa. O painel monta num commit posterior ao do `open`, então o foco
|
|
137
|
+
// volta no quadro seguinte, quando o controle de foco do Radix já agiu.
|
|
109
138
|
useEffect(() => {
|
|
110
139
|
if (!open) {
|
|
111
140
|
return;
|
|
@@ -115,6 +144,17 @@ export function Combobox({
|
|
|
115
144
|
return () => cancelAnimationFrame(quadro);
|
|
116
145
|
}, [open]);
|
|
117
146
|
|
|
147
|
+
// O item apontado precisa estar à vista: a lista rola até ele. A chamada é
|
|
148
|
+
// opcional porque o jsdom não implementa `scrollIntoView`.
|
|
149
|
+
useEffect(() => {
|
|
150
|
+
const alvo = visiveis[ativo];
|
|
151
|
+
if (!open || !alvo) {
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
itensRef.current[alvo.id]?.scrollIntoView?.({ block: "nearest" });
|
|
156
|
+
}, [ativo, open, visiveis]);
|
|
157
|
+
|
|
118
158
|
return (
|
|
119
159
|
<DropdownMenu open={open} onOpenChange={setOpen}>
|
|
120
160
|
<DropdownMenuTrigger asChild disabled={disabled}>
|
|
@@ -135,7 +175,9 @@ export function Combobox({
|
|
|
135
175
|
return;
|
|
136
176
|
}
|
|
137
177
|
event.preventDefault();
|
|
138
|
-
|
|
178
|
+
// Tecla que chega antes de o painel montar continua a busca em vez
|
|
179
|
+
// de recomeçá-la — senão a letra anterior se perde na abertura.
|
|
180
|
+
setSearch((atual) => (abertoRef.current ? atual + event.key : event.key));
|
|
139
181
|
setOpen(true);
|
|
140
182
|
}}
|
|
141
183
|
>
|
|
@@ -199,7 +241,32 @@ export function Combobox({
|
|
|
199
241
|
</DropdownMenuTrigger>
|
|
200
242
|
|
|
201
243
|
<DropdownMenuContent
|
|
202
|
-
|
|
244
|
+
/*
|
|
245
|
+
* Letra digitada no painel é da busca, esteja o foco onde estiver.
|
|
246
|
+
*
|
|
247
|
+
* Este é o antídoto para a busca por digitação do menu, que achava o
|
|
248
|
+
* item na lista e não escrevia nada no campo. Ela roubava as teclas
|
|
249
|
+
* sempre que o foco não estivesse no campo — no quadro que o efeito
|
|
250
|
+
* acima espera, ou com o ponteiro repousado sobre a lista. O Radix
|
|
251
|
+
* compõe os handlers com o de fora primeiro, e desiste do seu quando o
|
|
252
|
+
* evento já foi tratado: o `preventDefault` daqui é o que o desarma.
|
|
253
|
+
*/
|
|
254
|
+
onKeyDown={(event) => {
|
|
255
|
+
// Tecla nascida na busca já foi tratada lá.
|
|
256
|
+
if (
|
|
257
|
+
event.target === inputRef.current ||
|
|
258
|
+
event.key.length !== 1 ||
|
|
259
|
+
event.ctrlKey ||
|
|
260
|
+
event.metaKey ||
|
|
261
|
+
event.altKey
|
|
262
|
+
) {
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
event.preventDefault();
|
|
267
|
+
setSearch((atual) => atual + event.key);
|
|
268
|
+
inputRef.current?.focus();
|
|
269
|
+
}}
|
|
203
270
|
/*
|
|
204
271
|
* Largura do campo sempre; sobreposto só quando é de um valor só.
|
|
205
272
|
*
|
|
@@ -224,14 +291,20 @@ export function Combobox({
|
|
|
224
291
|
ref={inputRef}
|
|
225
292
|
value={search}
|
|
226
293
|
placeholder={searchPlaceholder ?? t("common.search")}
|
|
294
|
+
role="combobox"
|
|
295
|
+
aria-expanded
|
|
296
|
+
// Quem lê a tela precisa saber qual item as setas apontam: o foco
|
|
297
|
+
// fica aqui, então é este atributo que conta a navegação.
|
|
298
|
+
aria-activedescendant={
|
|
299
|
+
visiveis[ativo] ? `${idLista}-${visiveis[ativo].id}` : undefined
|
|
300
|
+
}
|
|
227
301
|
onChange={(event) => setSearch(event.target.value)}
|
|
228
302
|
/*
|
|
229
303
|
* Só o que é texto fica preso no campo.
|
|
230
304
|
*
|
|
231
305
|
* O menu do Radix tem busca por digitação própria: sem parar essas
|
|
232
306
|
* teclas, digitar "c" pularia o foco para o item "Chase" em vez de
|
|
233
|
-
* escrever.
|
|
234
|
-
* e aí não há como percorrer a lista pelo teclado.
|
|
307
|
+
* escrever.
|
|
235
308
|
*/
|
|
236
309
|
onKeyDown={(event) => {
|
|
237
310
|
if (event.key.length === 1) {
|
|
@@ -239,25 +312,24 @@ export function Combobox({
|
|
|
239
312
|
}
|
|
240
313
|
|
|
241
314
|
/*
|
|
242
|
-
*
|
|
315
|
+
* A seta anda pela lista sem tirar o foco do campo.
|
|
243
316
|
*
|
|
244
|
-
*
|
|
245
|
-
*
|
|
246
|
-
*
|
|
247
|
-
*
|
|
248
|
-
*
|
|
249
|
-
* bastou.
|
|
317
|
+
* Mover o foco para o item — como era aqui — entregava as teclas
|
|
318
|
+
* seguintes à busca por digitação do Radix: a letra saltava para
|
|
319
|
+
* outra opção em vez de entrar na busca, e do primeiro item a
|
|
320
|
+
* seta para cima dava a volta na lista sem nunca devolver o
|
|
321
|
+
* campo. Quem aponta agora é o `ativo`.
|
|
250
322
|
*/
|
|
251
323
|
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
)
|
|
255
|
-
|
|
256
|
-
event.preventDefault();
|
|
257
|
-
const alvo =
|
|
258
|
-
event.key === "ArrowDown" ? itens[0] : itens[itens.length - 1];
|
|
259
|
-
alvo.focus();
|
|
324
|
+
// Sem isto o cursor do campo salta para as pontas do texto.
|
|
325
|
+
event.preventDefault();
|
|
326
|
+
if (visiveis.length === 0) {
|
|
327
|
+
return;
|
|
260
328
|
}
|
|
329
|
+
|
|
330
|
+
const passo = event.key === "ArrowDown" ? 1 : -1;
|
|
331
|
+
const proximo = (ativo + passo + visiveis.length) % visiveis.length;
|
|
332
|
+
setApontado(visiveis[proximo].id);
|
|
261
333
|
return;
|
|
262
334
|
}
|
|
263
335
|
|
|
@@ -265,20 +337,39 @@ export function Combobox({
|
|
|
265
337
|
return;
|
|
266
338
|
}
|
|
267
339
|
event.stopPropagation();
|
|
268
|
-
// Enter escolhe o
|
|
269
|
-
//
|
|
340
|
+
// Enter escolhe o item apontado — sem isto, filtrar pelo teclado
|
|
341
|
+
// ainda terminaria no mouse.
|
|
270
342
|
event.preventDefault();
|
|
271
|
-
|
|
272
|
-
|
|
343
|
+
const escolhido = visiveis[ativo];
|
|
344
|
+
if (escolhido) {
|
|
345
|
+
alternar(escolhido.id);
|
|
273
346
|
}
|
|
274
347
|
}}
|
|
275
348
|
/>
|
|
276
349
|
</div>
|
|
277
350
|
|
|
278
|
-
{visiveis.map((option) => (
|
|
351
|
+
{visiveis.map((option, indice) => (
|
|
279
352
|
<DropdownMenuItem
|
|
280
353
|
key={option.id}
|
|
281
|
-
|
|
354
|
+
id={`${idLista}-${option.id}`}
|
|
355
|
+
ref={(node) => {
|
|
356
|
+
itensRef.current[option.id] = node;
|
|
357
|
+
}}
|
|
358
|
+
className={cn(
|
|
359
|
+
"flex w-full items-center justify-between",
|
|
360
|
+
// O apontado se destaca sozinho: o `focus:` do item não serve
|
|
361
|
+
// mais, porque o foco não sai do campo de busca.
|
|
362
|
+
indice === ativo && "bg-accent text-accent-foreground",
|
|
363
|
+
)}
|
|
364
|
+
/*
|
|
365
|
+
* O mouse aponta, mas não rouba o foco: o item do Radix se foca no
|
|
366
|
+
* `pointermove`, e bastava o ponteiro repousar sobre a lista para a
|
|
367
|
+
* digitação parar de chegar ao campo.
|
|
368
|
+
*/
|
|
369
|
+
onPointerMove={(event) => {
|
|
370
|
+
event.preventDefault();
|
|
371
|
+
setApontado(option.id);
|
|
372
|
+
}}
|
|
282
373
|
// Escolher **não** fecha por conta do Radix: no modo múltiplo,
|
|
283
374
|
// fechar a cada marcação obrigaria a reabrir para o próximo. Quem
|
|
284
375
|
// fecha é o `alternar`, e só quando é de um valor só.
|
|
@@ -38,6 +38,18 @@ export interface DatePickerProps {
|
|
|
38
38
|
/** Descreve o campo para quem usa leitor de tela. */
|
|
39
39
|
label?: string;
|
|
40
40
|
placeholder?: string;
|
|
41
|
+
/**
|
|
42
|
+
* O primeiro dia escolhível (`"yyyy-MM-dd"`) — os anteriores ficam apagados
|
|
43
|
+
* no calendário e o digitado é recusado.
|
|
44
|
+
*
|
|
45
|
+
* Existe porque avisar depois não serve para data: o vencimento anterior à
|
|
46
|
+
* compra e o pagamento anterior ao lançamento são erros que a pessoa comete
|
|
47
|
+
* sem perceber, e descobrir no botão Salvar é refazer o caminho. Aqui o dia
|
|
48
|
+
* simplesmente não se deixa escolher.
|
|
49
|
+
*/
|
|
50
|
+
minDay?: string;
|
|
51
|
+
/** O último dia escolhível, pela mesma razão. */
|
|
52
|
+
maxDay?: string;
|
|
41
53
|
}
|
|
42
54
|
|
|
43
55
|
/**
|
|
@@ -69,6 +81,8 @@ export function DatePicker({
|
|
|
69
81
|
disabled,
|
|
70
82
|
label,
|
|
71
83
|
placeholder,
|
|
84
|
+
minDay,
|
|
85
|
+
maxDay,
|
|
72
86
|
}: DatePickerProps): JSX.Element {
|
|
73
87
|
const { t, locale } = useI18n();
|
|
74
88
|
const [open, setOpen] = useState(false);
|
|
@@ -104,8 +118,24 @@ export function DatePicker({
|
|
|
104
118
|
caret.current = null;
|
|
105
119
|
});
|
|
106
120
|
|
|
121
|
+
/**
|
|
122
|
+
* O dia está dentro da faixa permitida?
|
|
123
|
+
*
|
|
124
|
+
* Comparação de texto ISO, e não de `Date`: `2026-09-03 < 2026-09-08` é
|
|
125
|
+
* verdade em ordem alfabética porque o formato é fixo, e assim não há
|
|
126
|
+
* conversão de fuso no meio para transformar o dia 1º no dia 30.
|
|
127
|
+
*/
|
|
128
|
+
const dayAllowed = (iso: string): boolean =>
|
|
129
|
+
(!minDay || iso >= minDay) && (!maxDay || iso <= maxDay);
|
|
130
|
+
|
|
107
131
|
const pickDay = (day: Date): void => {
|
|
108
|
-
|
|
132
|
+
const iso = toIsoDay(day);
|
|
133
|
+
|
|
134
|
+
if (!dayAllowed(iso)) {
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
onChange(iso);
|
|
109
139
|
setTyped(null);
|
|
110
140
|
setOpen(false);
|
|
111
141
|
};
|
|
@@ -129,7 +159,9 @@ export function DatePicker({
|
|
|
129
159
|
(selected ?? today()).getFullYear(),
|
|
130
160
|
);
|
|
131
161
|
|
|
132
|
-
|
|
162
|
+
// Digitar é o outro caminho para o mesmo campo: sem esta guarda, o dia que
|
|
163
|
+
// o calendário recusa entraria pelo teclado.
|
|
164
|
+
if (iso && dayAllowed(iso)) {
|
|
133
165
|
onChange(iso);
|
|
134
166
|
setMonth(fromIsoDay(iso) ?? month);
|
|
135
167
|
}
|
|
@@ -180,6 +212,11 @@ export function DatePicker({
|
|
|
180
212
|
};
|
|
181
213
|
|
|
182
214
|
const dayClass = (day: Date): string => {
|
|
215
|
+
if (!dayAllowed(toIsoDay(day))) {
|
|
216
|
+
// Continua desenhado, e não escondido: o calendário sem os dias 1 a 7
|
|
217
|
+
// pareceria quebrado, e é o cinza que diz "existe, mas não aqui".
|
|
218
|
+
return "text-muted-foreground/30 line-through";
|
|
219
|
+
}
|
|
183
220
|
if (selected && isSameDay(day, selected)) {
|
|
184
221
|
return "bg-primary text-primary-foreground font-semibold";
|
|
185
222
|
}
|
|
@@ -285,9 +322,13 @@ export function DatePicker({
|
|
|
285
322
|
<button
|
|
286
323
|
key={day.toISOString()}
|
|
287
324
|
type="button"
|
|
325
|
+
disabled={!dayAllowed(toIsoDay(day))}
|
|
288
326
|
onClick={() => pickDay(day)}
|
|
289
327
|
className={cn(
|
|
290
|
-
"flex h-8 w-8 items-center justify-center rounded-full text-sm transition-colors
|
|
328
|
+
"flex h-8 w-8 items-center justify-center rounded-full text-sm transition-colors",
|
|
329
|
+
dayAllowed(toIsoDay(day))
|
|
330
|
+
? "hover:bg-accent"
|
|
331
|
+
: "cursor-not-allowed hover:bg-transparent",
|
|
291
332
|
dayClass(day),
|
|
292
333
|
)}
|
|
293
334
|
>
|
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
DialogHeader,
|
|
20
20
|
DialogTitle,
|
|
21
21
|
Field,
|
|
22
|
+
HoverTip,
|
|
22
23
|
Input,
|
|
23
24
|
Label,
|
|
24
25
|
} from "#core/components/ui";
|
|
@@ -151,23 +152,29 @@ export function RolesPanel(): JSX.Element {
|
|
|
151
152
|
{canManage && (
|
|
152
153
|
<div className="flex">
|
|
153
154
|
{hasPermission("roles:update:any") && (
|
|
154
|
-
<
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
155
|
+
<HoverTip label={t("common.edit")}>
|
|
156
|
+
<Button
|
|
157
|
+
aria-label={t("common.edit")}
|
|
158
|
+
variant="ghost"
|
|
159
|
+
size="icon"
|
|
160
|
+
onClick={() => openEdit(role)}
|
|
161
|
+
>
|
|
162
|
+
<Pencil className="h-4 w-4" />
|
|
163
|
+
</Button>
|
|
164
|
+
</HoverTip>
|
|
161
165
|
)}
|
|
162
166
|
{hasPermission("roles:delete:any") && (
|
|
163
|
-
<
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
167
|
+
<HoverTip label={t("common.delete")}>
|
|
168
|
+
<Button
|
|
169
|
+
aria-label={t("common.delete")}
|
|
170
|
+
variant="ghost"
|
|
171
|
+
size="icon"
|
|
172
|
+
className="text-destructive"
|
|
173
|
+
onClick={() => remove(role)}
|
|
174
|
+
>
|
|
175
|
+
<Trash2 className="h-4 w-4" />
|
|
176
|
+
</Button>
|
|
177
|
+
</HoverTip>
|
|
171
178
|
)}
|
|
172
179
|
</div>
|
|
173
180
|
)}
|