rl-core-front 0.16.6 → 0.16.8
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
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
breadcrumbTrail,
|
|
10
10
|
} from "#core/_utils/breadcrumb-trail";
|
|
11
11
|
import { useI18n } from "#core/contexts";
|
|
12
|
+
import { useDocumentTitle } from "#core/hooks/use-document-title";
|
|
12
13
|
|
|
13
14
|
/**
|
|
14
15
|
* Rótulo das telas do próprio core — o projeto não precisa cadastrá-las.
|
|
@@ -36,6 +37,11 @@ export function Breadcrumbs({ navItems }: BreadcrumbsProps): JSX.Element {
|
|
|
36
37
|
|
|
37
38
|
const trail = breadcrumbTrail(pathname, navItems, SEGMENT_LABELS);
|
|
38
39
|
|
|
40
|
+
// A aba do navegador acompanha a migalha: o último degrau é a tela, e é o
|
|
41
|
+
// mesmo nome nos dois lugares — inclusive nas telas do próprio core.
|
|
42
|
+
const ultimo = trail[trail.length - 1];
|
|
43
|
+
useDocumentTitle(ultimo ? t(ultimo.label) : null);
|
|
44
|
+
|
|
39
45
|
return (
|
|
40
46
|
<nav className="mb-4 flex items-center gap-1.5 text-sm text-muted-foreground">
|
|
41
47
|
<button
|
|
@@ -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ó.
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect } from "react";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* O título com que a página carregou — o que o `metadata` do projeto mandou.
|
|
7
|
+
*
|
|
8
|
+
* Lido uma vez por carregamento e nunca reescrito: é o sufixo de todos os
|
|
9
|
+
* títulos daqui em diante, e o projeto não precisa dizer duas vezes como se
|
|
10
|
+
* chama (o prefixo "Dev - " que ele põe no `metadata` vem junto de graça).
|
|
11
|
+
*/
|
|
12
|
+
let baseTitle: string | null = null;
|
|
13
|
+
|
|
14
|
+
/** O que fica entre o nome da tela e o do sistema. */
|
|
15
|
+
const SEPARATOR = " · ";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* A aba do navegador diz em que tela se está.
|
|
19
|
+
*
|
|
20
|
+
* Com cinco abas do sistema abertas, todas se chamavam só pelo nome dele — e
|
|
21
|
+
* achar a do financeiro era clicar uma a uma. A tela vem **antes** do nome do
|
|
22
|
+
* sistema, e não depois: a aba trunca pelo fim, e "ControlX - Controle
|
|
23
|
+
* fina…" vira "ControlX" em todas.
|
|
24
|
+
*
|
|
25
|
+
* `null` devolve o título de origem — é o que a tela sem migalha faz.
|
|
26
|
+
*/
|
|
27
|
+
export const useDocumentTitle = (title: string | null): void => {
|
|
28
|
+
useEffect(() => {
|
|
29
|
+
if (baseTitle === null) {
|
|
30
|
+
baseTitle = document.title;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (!title) {
|
|
34
|
+
document.title = baseTitle;
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
document.title = baseTitle ? `${title}${SEPARATOR}${baseTitle}` : title;
|
|
39
|
+
}, [title]);
|
|
40
|
+
};
|
package/src/index.ts
CHANGED
|
@@ -82,6 +82,7 @@ export type {
|
|
|
82
82
|
UseConfirmResult,
|
|
83
83
|
} from "#core/hooks/use-confirm";
|
|
84
84
|
export { useConfirm } from "#core/hooks/use-confirm";
|
|
85
|
+
export { useDocumentTitle } from "#core/hooks/use-document-title";
|
|
85
86
|
export type { ExportQuery, UseExportResult } from "#core/hooks/use-export";
|
|
86
87
|
export { useExport } from "#core/hooks/use-export";
|
|
87
88
|
export type { UseFilterSchemaResult } from "#core/hooks/use-filter-schema";
|