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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rl-core-front",
3
- "version": "0.16.6",
3
+ "version": "0.16.8",
4
4
  "description": "Telas e componentes Next.js do core: login com 2FA, usuários, RBAC, auditoria, logs e listagens com filtro dinâmico",
5
5
  "author": "Rodrigo Liberti",
6
6
  "license": "MIT",
@@ -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 painelRef = useRef<HTMLDivElement>(null);
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. `onOpenAutoFocus` é prop privada do `DropdownMenu` e não dá para
107
- // interceptar, então o foco volta no quadro seguinte, quando o controle de
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
- setSearch(event.key);
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
- ref={painelRef}
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. Mas parar **tudo** — como estava — leva junto as setas,
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
- * O primeiro salto do campo para a lista é por conta da gente.
315
+ * A seta anda pela lista sem tirar o foco do campo.
243
316
  *
244
- * O Radix navega entre os itens com as setas, mas depois que o
245
- * foco 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.
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
- 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();
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 que a busca deixou na tela — sem isto, filtrar
269
- // pelo teclado ainda terminaria no mouse.
340
+ // Enter escolhe o item apontado — sem isto, filtrar pelo teclado
341
+ // ainda terminaria no mouse.
270
342
  event.preventDefault();
271
- if (visiveis[0]) {
272
- alternar(visiveis[0].id);
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
- className="flex w-full items-center justify-between"
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";