rl-core-front 0.19.0 → 0.19.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.19.0",
3
+ "version": "0.19.1",
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",
@@ -8,6 +8,7 @@ import { Button, type ButtonProps } from "#core/components/ui/button";
8
8
  import {
9
9
  EXPAND_SHORTCUT_KEY,
10
10
  EXPAND_SHORTCUT_LABEL,
11
+ SUBMIT_SHORTCUT_KEY,
11
12
  useShortcut,
12
13
  } from "#core/hooks/use-shortcut";
13
14
  import { cn } from "#core/lib/utils";
@@ -428,10 +429,32 @@ const DialogContent = React.forwardRef<
428
429
  );
429
430
  DialogContent.displayName = DialogPrimitive.Content.displayName;
430
431
 
432
+ /**
433
+ * O modal aberto por cima de todos os outros.
434
+ *
435
+ * O Radix pendura cada modal no fim do `body`, então o último no DOM é o que
436
+ * está por cima. É como o formulário sabe se o atalho é dele: com uma
437
+ * confirmação aberta sobre o cadastro, os dois têm formulário e só o de cima
438
+ * pode responder ao `Ctrl + Enter`.
439
+ */
440
+ const topmostDialog = (): Element | null => {
441
+ const open = document.querySelectorAll('[role="dialog"][data-state="open"]');
442
+
443
+ return open[open.length - 1] ?? null;
444
+ };
445
+
431
446
  /**
432
447
  * Corpo do modal como formulário — é o que faz o `Enter` submeter.
433
448
  * O `preventDefault` fica aqui porque, sem ele, o Enter recarrega a página.
434
449
  *
450
+ * E o `Ctrl + Enter` envia de **qualquer lugar** do modal. O Enter sozinho só
451
+ * envia a partir de um campo de texto: num select ele abre a lista, numa
452
+ * caixa de marcar ele marca, num texto longo ele quebra a linha — e quem está
453
+ * com o foco ali ficava sem saída pelo teclado. O atalho passa pelo mesmo
454
+ * caminho do botão Salvar (`requestSubmit` com ele como `submitter`), então
455
+ * a validação do HTML roda igual e o botão desativado — salvando, ou sem
456
+ * permissão — continua mandando.
457
+ *
435
458
  * O `DialogFooter` é separado dos demais filhos e fica **fora da área
436
459
  * rolável**, ainda dentro do `<form>`. As duas metades importam: fora do
437
460
  * scroll porque `sticky` deixa o conteúdo correr por baixo dos botões — com
@@ -449,10 +472,33 @@ const DialogForm = React.forwardRef<
449
472
  const items = React.Children.toArray(children);
450
473
  const footerAt = indexOfChild(items, "DialogFooter");
451
474
  const hasFooter = footerAt >= 0;
475
+ const formRef = React.useRef<HTMLFormElement>(null);
476
+
477
+ const submitFromAnywhere = (): void => {
478
+ const form = formRef.current;
479
+
480
+ if (!form || form.closest('[role="dialog"]') !== topmostDialog()) {
481
+ return;
482
+ }
483
+
484
+ const submitter = form.querySelector<HTMLButtonElement>('button[type="submit"]');
485
+
486
+ if (submitter?.disabled) {
487
+ return;
488
+ }
489
+
490
+ form.requestSubmit(submitter ?? undefined);
491
+ };
492
+
493
+ useShortcut(SUBMIT_SHORTCUT_KEY, submitFromAnywhere, {
494
+ ctrl: true,
495
+ allowInDialog: true,
496
+ allowWhileTyping: true,
497
+ });
452
498
 
453
499
  return (
454
500
  <form
455
- ref={ref}
501
+ ref={mergeRefs(ref, formRef)}
456
502
  onSubmit={(e) => {
457
503
  e.preventDefault();
458
504
  onSubmit?.(e);
@@ -478,6 +524,19 @@ const DialogForm = React.forwardRef<
478
524
  });
479
525
  DialogForm.displayName = "DialogForm";
480
526
 
527
+ /** O ref de quem usa e o nosso, no mesmo elemento. */
528
+ const mergeRefs =
529
+ <T,>(...refs: React.Ref<T>[]) =>
530
+ (node: T | null): void => {
531
+ for (const ref of refs) {
532
+ if (typeof ref === "function") {
533
+ ref(node);
534
+ } else if (ref) {
535
+ (ref as React.MutableRefObject<T | null>).current = node;
536
+ }
537
+ }
538
+ };
539
+
481
540
  const DialogTitle = React.forwardRef<
482
541
  React.ElementRef<typeof DialogPrimitive.Title>,
483
542
  React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
@@ -0,0 +1,36 @@
1
+ "use client";
2
+
3
+ import { HelpCircle } from "lucide-react";
4
+ import type { JSX } from "react";
5
+
6
+ import { HoverTip } from "#core/components/ui/hover-tip";
7
+
8
+ export interface HelpHintProps {
9
+ /** O texto que explica a tela. */
10
+ text: string;
11
+ /** Como o botão se apresenta a quem usa leitor de tela. */
12
+ label: string;
13
+ }
14
+
15
+ /**
16
+ * A explicação da tela, atrás de um ícone.
17
+ *
18
+ * O texto vivia solto acima da tabela, e ali ele é lido uma vez e depois vira
19
+ * uma linha que empurra a lista para baixo todo dia. No ícone ele continua ao
20
+ * alcance de quem chegou agora, sem ocupar espaço de quem já sabe.
21
+ *
22
+ * É um `HoverTip` com o botão de interrogação dentro: abre ao passar o mouse
23
+ * — ler uma explicação não deveria custar um clique — e também no foco, que é
24
+ * como ela chega a quem está no teclado.
25
+ */
26
+ export const HelpHint = ({ text, label }: HelpHintProps): JSX.Element => (
27
+ <HoverTip label={text}>
28
+ <button
29
+ type="button"
30
+ aria-label={label}
31
+ className="flex h-9 w-9 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
32
+ >
33
+ <HelpCircle className="h-4 w-4" />
34
+ </button>
35
+ </HoverTip>
36
+ );
@@ -1,79 +1,105 @@
1
1
  "use client";
2
2
 
3
- import type { FocusEvent, JSX, MouseEvent, ReactNode } from "react";
4
- import { useState } from "react";
5
- import { createPortal } from "react-dom";
3
+ import type { JSX, ReactNode } from "react";
4
+ import { useEffect, useRef, useState } from "react";
5
+
6
+ import {
7
+ Popover,
8
+ PopoverAnchor,
9
+ PopoverContent,
10
+ } from "#core/components/ui/popover";
11
+ import { cn } from "#core/lib/utils";
6
12
 
7
13
  export interface HoverTipProps {
8
14
  /** O que o tooltip diz. Vazio não desenha nada. */
9
15
  label: string;
10
16
  children: ReactNode;
17
+ /** Classe extra do painel — para alargar quando o texto é uma explicação. */
18
+ className?: string;
11
19
  }
12
20
 
13
- /** Onde o rótulo aparece na tela, em coordenada de viewport. */
14
- interface TipPosition {
15
- x: number;
16
- y: number;
17
- }
18
-
19
- /** A folga entre o gatilho e o rótulo. */
20
- const GAP = 6;
21
+ /**
22
+ * Quanto o fechar espera, em milissegundos.
23
+ *
24
+ * Entre o gatilho e o painel existe a folga do `sideOffset`: fechando no
25
+ * primeiro `mouseleave`, o texto sumia no meio do caminho de quem foi até ele
26
+ * para ler inteiro.
27
+ */
28
+ const CLOSE_GRACE_MS = 150;
21
29
 
22
30
  /**
23
31
  * O rótulo que aparece **na hora** ao passar o mouse.
24
32
  *
25
- * Existe pelo mesmo motivo do `HoverLabel` dos gráficos: o `title` do HTML
26
- * aparece depois de cerca de um segundo parado, e um segundo é tempo suficiente
27
- * para quem passou o mouse concluir que não há nada ali e seguir em frente.
28
- * Numa coluna de cinco ícones, isso é a diferença entre descobrir o que cada um
29
- * faz e clicar para descobrir.
33
+ * Existe porque o `title` do HTML aparece depois de cerca de um segundo
34
+ * parado, e um segundo é tempo suficiente para quem passou o mouse concluir
35
+ * que não há nada ali e seguir em frente. Numa coluna de cinco ícones, isso é
36
+ * a diferença entre descobrir o que cada um faz e clicar para descobrir.
37
+ *
38
+ * É o `Popover` do core por baixo, e por isso tem a cara dele — o mesmo
39
+ * painel da explicação de tela (`HelpHint`) e do calendário: um desenho só
40
+ * para tudo que flutua. Do Radix vêm o portal (o painel não é cortado pela
41
+ * borda da tabela nem do modal) e a virada para cima quando não cabe embaixo.
30
42
  *
31
- * Abre **embaixo**, e sai do documento por um portal com posição fixa. As duas
32
- * decisões são a mesma: o `Table` do core envolve a tabela num `overflow-x-auto`
33
- * e, quando um eixo rola, o navegador recorta o outro também — dentro da célula,
34
- * o rótulo da última linha era cortado pela borda da tabela. Abrir à esquerda,
35
- * como já se tentou, cabia na caixa mas cobria o conteúdo da própria linha: na
36
- * lista de receitas, o "Recebido" do botão tapava o "Pendente" da situação, que
37
- * é justamente o dado que se confere antes de clicar.
43
+ * Ancorado, e não gatilhado: um `PopoverTrigger` faria do botão de editar um
44
+ * "abre um diálogo" para o leitor de tela. Aqui quem abre é o mouse e o foco,
45
+ * e o painel se apresenta como `tooltip`.
38
46
  *
39
- * O estado mora aqui dentro e guarda a coordenada. Ele re-renderiza este
40
- * componente e o rótulo não a linha da tabela, que entra por `children` e
41
- * chega pronta de fora.
47
+ * Abre também no foco quem navega com Tab precisa do mesmo texto que o
48
+ * mouse mostra e não rouba o foco ao abrir: aberto pelo mouse, ele levaria
49
+ * o cursor de teclado para longe do botão que a pessoa nem clicou.
42
50
  */
43
- export const HoverTip = ({ label, children }: HoverTipProps): JSX.Element => {
44
- const [position, setPosition] = useState<TipPosition | null>(null);
51
+ export const HoverTip = ({ label, children, className }: HoverTipProps): JSX.Element => {
52
+ const [open, setOpen] = useState(false);
53
+ const closing = useRef<ReturnType<typeof setTimeout> | null>(null);
45
54
 
46
- const show = (event: MouseEvent<HTMLElement> | FocusEvent<HTMLElement>): void => {
47
- const box = event.currentTarget.getBoundingClientRect();
55
+ const show = (): void => {
56
+ if (closing.current) {
57
+ clearTimeout(closing.current);
58
+ closing.current = null;
59
+ }
60
+ setOpen(true);
61
+ };
48
62
 
49
- setPosition({ x: box.left + box.width / 2, y: box.bottom + GAP });
63
+ const hide = (): void => {
64
+ closing.current = setTimeout(() => setOpen(false), CLOSE_GRACE_MS);
50
65
  };
51
66
 
52
- const hide = (): void => setPosition(null);
67
+ // O timer pendente não pode sobreviver ao componente: a linha da tabela
68
+ // some ao excluir, e o `setOpen` chegaria num componente desmontado.
69
+ useEffect(
70
+ () => () => {
71
+ if (closing.current) {
72
+ clearTimeout(closing.current);
73
+ }
74
+ },
75
+ [],
76
+ );
53
77
 
54
78
  return (
55
- <span
56
- className="relative inline-flex"
57
- onMouseEnter={show}
58
- onMouseLeave={hide}
59
- // Chegando por teclado o rótulo também aparece: quem navega com Tab
60
- // precisa do mesmo texto que o mouse mostra.
61
- onFocus={show}
62
- onBlur={hide}
63
- >
64
- {children}
65
- {label &&
66
- position &&
67
- createPortal(
68
- <span
69
- role="tooltip"
70
- style={{ left: position.x, top: position.y }}
71
- className="pointer-events-none fixed z-100 max-w-[90vw] -translate-x-1/2 whitespace-nowrap rounded-md border border-border bg-popover px-2 py-0.5 text-[11px] font-medium text-popover-foreground shadow-lg"
72
- >
73
- {label}
74
- </span>,
75
- document.body,
76
- )}
77
- </span>
79
+ <Popover open={open && label !== ""} onOpenChange={setOpen}>
80
+ <PopoverAnchor asChild>
81
+ <span
82
+ className="relative inline-flex"
83
+ onMouseEnter={show}
84
+ onMouseLeave={hide}
85
+ onFocus={show}
86
+ onBlur={hide}
87
+ >
88
+ {children}
89
+ </span>
90
+ </PopoverAnchor>
91
+ <PopoverContent
92
+ role="tooltip"
93
+ side="bottom"
94
+ align="center"
95
+ onOpenAutoFocus={(event) => event.preventDefault()}
96
+ onCloseAutoFocus={(event) => event.preventDefault()}
97
+ onMouseEnter={show}
98
+ onMouseLeave={hide}
99
+ className={cn("max-w-xs text-xs leading-relaxed text-muted-foreground", className)}
100
+ >
101
+ {label}
102
+ </PopoverContent>
103
+ </Popover>
78
104
  );
79
105
  };
@@ -21,6 +21,7 @@ export * from "./filter-field";
21
21
  export * from "./filter-group";
22
22
  export * from "./filter-rule";
23
23
  export * from "./filter-sheet";
24
+ export * from "./help-hint";
24
25
  export * from "./hover-tip";
25
26
  export * from "./image-upload";
26
27
  export * from "./input";
@@ -28,6 +29,7 @@ export * from "./job-progress";
28
29
  export * from "./label";
29
30
  export * from "./money-input";
30
31
  export * from "./password-requirements";
32
+ export * from "./period-nav";
31
33
  export * from "./popover";
32
34
  export * from "./row-actions";
33
35
  export * from "./segmented-control";
@@ -0,0 +1,104 @@
1
+ "use client";
2
+
3
+ import { ChevronLeft, ChevronRight } from "lucide-react";
4
+ import type { JSX } from "react";
5
+
6
+ import { Button } from "#core/components/ui/button";
7
+ import { useI18n } from "#core/contexts";
8
+ import { NEXT_ARROW_KEY, PREVIOUS_ARROW_KEY, useShortcut } from "#core/hooks/use-shortcut";
9
+ import { cn } from "#core/lib/utils";
10
+
11
+ export interface PeriodNavProps {
12
+ /** O período em exibição, já formatado — "setembro de 2026", "2026". */
13
+ label: string;
14
+ onPrevious: () => void;
15
+ onNext: () => void;
16
+ /** O nome do gesto, para o leitor de tela: "Mês anterior". Ausente, "Período anterior". */
17
+ previousLabel?: string;
18
+ /** Idem: "Próximo mês". Ausente, "Próximo período". */
19
+ nextLabel?: string;
20
+ /**
21
+ * Classe do rótulo — é onde se fixa a largura.
22
+ *
23
+ * Fixa, e não do tamanho do texto: sem isso "março" e "setembro" empurram
24
+ * o botão da direita para lugares diferentes, e a seta que a pessoa ia
25
+ * clicar sai de baixo do mouse ao trocar o mês.
26
+ */
27
+ labelClassName?: string;
28
+ /** Desliga os dois botões e o atalho, enquanto a tela carrega. */
29
+ disabled?: boolean;
30
+ }
31
+
32
+ /**
33
+ * Anda de período — mês, ano — na tela que tem um só para tudo.
34
+ *
35
+ * Os dois botões e, junto deles, as setas do teclado: `←` volta, `→` avança.
36
+ * Existe como componente pelo mesmo motivo do `CreateButton`: quem põe o
37
+ * seletor ganha o atalho no mesmo gesto, e tela nova nasce com ele em vez de
38
+ * herdá-lo por disciplina.
39
+ *
40
+ * A seta é solta — sem `Alt`, que é o "voltar" do navegador no Windows —, e
41
+ * vale com o foco em qualquer lugar da tela, inclusive numa aba: as abas do
42
+ * core não andam por seta solta justamente para a tecla ter um dono só
43
+ * (`Alt` + seta é o delas). O `useShortcut` ainda cede a vez a menu, lista
44
+ * e slider, que andam por setas dentro de si.
45
+ *
46
+ * Sem dica nos botões: o atalho está na tela de atalhos, e o painel a cada
47
+ * passada do mouse atrapalhava mais do que ensinava.
48
+ *
49
+ * ```tsx
50
+ * <PeriodNav
51
+ * label={formatMonthLong(month)}
52
+ * onPrevious={() => setMonth(shiftMonth(month, -1))}
53
+ * onNext={() => setMonth(shiftMonth(month, 1))}
54
+ * previousLabel={t("finance.month.previous")}
55
+ * nextLabel={t("finance.month.next")}
56
+ * labelClassName="w-40"
57
+ * />
58
+ * ```
59
+ */
60
+ export const PeriodNav = ({
61
+ label,
62
+ onPrevious,
63
+ onNext,
64
+ previousLabel,
65
+ nextLabel,
66
+ labelClassName,
67
+ disabled = false,
68
+ }: PeriodNavProps): JSX.Element => {
69
+ const { t } = useI18n();
70
+ const previousText = previousLabel ?? t("common.previousPeriod");
71
+ const nextText = nextLabel ?? t("common.nextPeriod");
72
+
73
+ useShortcut(PREVIOUS_ARROW_KEY, onPrevious, { enabled: !disabled });
74
+ useShortcut(NEXT_ARROW_KEY, onNext, { enabled: !disabled });
75
+
76
+ return (
77
+ <div className="flex items-center gap-1">
78
+ <Button
79
+ variant="outline"
80
+ size="iconSm"
81
+ aria-label={previousText}
82
+ disabled={disabled}
83
+ onClick={onPrevious}
84
+ >
85
+ <ChevronLeft className="h-4 w-4" />
86
+ </Button>
87
+ <span
88
+ className={cn("text-center text-sm font-medium tabular-nums", labelClassName)}
89
+ aria-live="polite"
90
+ >
91
+ {label}
92
+ </span>
93
+ <Button
94
+ variant="outline"
95
+ size="iconSm"
96
+ aria-label={nextText}
97
+ disabled={disabled}
98
+ onClick={onNext}
99
+ >
100
+ <ChevronRight className="h-4 w-4" />
101
+ </Button>
102
+ </div>
103
+ );
104
+ };
@@ -2,76 +2,50 @@
2
2
 
3
3
  import type { JSX } from "react";
4
4
 
5
- import { MAX_TAB_SHORTCUTS, useShortcut } from "#core/hooks/use-shortcut";
5
+ import {
6
+ NEXT_ARROW_KEY,
7
+ PREVIOUS_ARROW_KEY,
8
+ useShortcut,
9
+ } from "#core/hooks/use-shortcut";
6
10
 
7
11
  export interface TabShortcutsProps {
8
12
  /** Os valores das abas, na ordem em que aparecem. */
9
13
  values: readonly string[];
14
+ /** A aba aberta — é dela que se conta a anterior e a seguinte. */
15
+ value: string;
10
16
  onSelect: (value: string) => void;
11
17
  }
12
18
 
13
19
  /**
14
- * O rótulo do atalho de uma aba, para a dica que a própria aba mostra.
20
+ * `Alt` + `←`/`→` troca de aba. Não desenha nada.
15
21
  *
16
- * `⌥` no Mac e `Alt` no resto: é a mesma tecla física, mas ninguém procura
17
- * "Alt" num teclado da Apple. Fora do navegador (SSR) responde `Alt`, que é o
18
- * que o primeiro render precisa escrever a correção vem no cliente.
19
- */
20
- export const tabShortcutLabel = (index: number): string => {
21
- const mac =
22
- typeof navigator !== "undefined" && /Mac|iPhone|iPad/.test(navigator.platform);
23
-
24
- return `${mac ? "⌥" : "Alt"} ${index + 1}`;
25
- };
26
-
27
- /**
28
- * Um atalho, uma instância.
22
+ * Com `Alt`, e não a seta solta: a seta solta é o atalho de período da tela
23
+ * (`PeriodNav`), e uma tecla com dois donos é o conflito que aparece na
24
+ * tela onde os dois existem. `Alt` + número, que foi o atalho daqui, pedia
25
+ * decorar a posição de cada aba; a seta só pede saber para que lado ir.
29
26
  *
30
- * O `useShortcut` aceita uma lista de teclas mas chama o handler sem dizer
31
- * **qual** disparou. Com um componente por aba, cada instância fica com a sua
32
- * tecla e a sua ação, e nenhum hook roda dentro de laço — que é o que a regra
33
- * dos hooks proíbe.
34
- */
35
- const TabShortcut = ({
36
- digit,
37
- value,
38
- onSelect,
39
- }: {
40
- digit: number;
41
- value: string;
42
- onSelect: (value: string) => void;
43
- }): null => {
44
- /*
45
- Por `code`, e não por `key`: no macOS o `Option` + número não produz o
46
- número — `⌥1` chega como `¡` e `⌥2` como `™`. Declarado como "1", o atalho
47
- funcionava no Windows e falhava no Mac, que é o pior desfecho possível.
48
- */
49
- useShortcut(`Digit${digit}`, () => onSelect(value), { alt: true, byCode: true });
50
-
51
- return null;
52
- };
53
-
54
- /**
55
- * `Alt` + `1..9` troca de aba. Não desenha nada.
56
- *
57
- * Com `Alt`, e não a tecla sozinha: os números precisam continuar sendo
58
- * digitáveis, e o `1` sem modificador já é o atalho de abrir cadastro — dois
59
- * donos para a mesma tecla é o conflito que só aparece na tela onde os dois
60
- * existem.
27
+ * a volta: da última, `→` vai para a primeira. É o que o `Ctrl + Tab` do
28
+ * navegador faz, e é o que a mão espera de uma fila.
61
29
  *
62
- * A ordem das abas é a fonte da numeração: passe a mesma lista que desenha os
30
+ * A ordem das abas é a fonte da sequência: passe a mesma lista que desenha os
63
31
  * gatilhos, e não uma cópia. Duas fontes para a mesma ordem é o jeito garantido
64
- * de `Alt + 3` abrir a aba errada no dia em que alguém reordenar a barra.
32
+ * de a seta pular para a aba errada no dia em que alguém reordenar a barra.
65
33
  */
66
- export const TabShortcuts = ({ values, onSelect }: TabShortcutsProps): JSX.Element => (
67
- <>
68
- {values.slice(0, MAX_TAB_SHORTCUTS).map((value, index) => (
69
- <TabShortcut
70
- key={value}
71
- digit={index + 1}
72
- value={value}
73
- onSelect={onSelect}
74
- />
75
- ))}
76
- </>
77
- );
34
+ export const TabShortcuts = ({ values, value, onSelect }: TabShortcutsProps): JSX.Element => {
35
+ const step = (offset: number): void => {
36
+ if (values.length === 0) {
37
+ return;
38
+ }
39
+ // Aba desconhecida (a URL trouxe um valor que já não existe) conta como a
40
+ // primeira: a seta leva a uma aba real em vez de a lugar nenhum.
41
+ const current = Math.max(values.indexOf(value), 0);
42
+ const next = (current + offset + values.length) % values.length;
43
+
44
+ onSelect(values[next]);
45
+ };
46
+
47
+ useShortcut(PREVIOUS_ARROW_KEY, () => step(-1), { alt: true });
48
+ useShortcut(NEXT_ARROW_KEY, () => step(1), { alt: true });
49
+
50
+ return <></>;
51
+ };
@@ -30,13 +30,33 @@ const TabsList = React.forwardRef<
30
30
  ));
31
31
  TabsList.displayName = TabsPrimitive.List.displayName;
32
32
 
33
+ /** As teclas com que o Radix andaria de aba — e que aqui ficam para a tela. */
34
+ const ARROW_KEYS = new Set(["ArrowLeft", "ArrowRight"]);
35
+
36
+ /**
37
+ * A aba do core **não anda por seta**.
38
+ *
39
+ * O Radix trocaria de aba com `←`/`→` enquanto o foco está numa delas — e a
40
+ * aba já tem atalho, `Alt` + número, que funciona de qualquer lugar. Deixar
41
+ * os dois fazia a seta ter dois donos na mesma tela: depois de clicar numa
42
+ * aba, `→` trocava a aba; clicando no fundo, trocava o mês. Impedir o padrão
43
+ * aqui, antes do handler do Radix, devolve a seta ao atalho da tela.
44
+ */
33
45
  const TabsTrigger = React.forwardRef<
34
46
  React.ElementRef<typeof TabsPrimitive.Trigger>,
35
47
  React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
36
- >(({ className, ...props }, ref) => (
48
+ >(({ className, onKeyDown, ...props }, ref) => (
37
49
  <TabsPrimitive.Trigger
38
50
  ref={ref}
39
51
  className={cn(TRACK_ITEM_CLASS, className)}
52
+ // O Radix só trata a tecla se ninguém impediu o padrão antes: o
53
+ // `onKeyDown` de quem usa continua valendo, e o nosso vem em seguida.
54
+ onKeyDown={(event) => {
55
+ onKeyDown?.(event);
56
+ if (ARROW_KEYS.has(event.key)) {
57
+ event.preventDefault();
58
+ }
59
+ }}
40
60
  {...props}
41
61
  />
42
62
  ));
@@ -1,11 +1,12 @@
1
1
  "use client";
2
2
 
3
3
  import {
4
+ ArrowLeftRight,
5
+ CalendarRange,
4
6
  CornerDownLeft,
5
7
  Keyboard,
6
8
  Maximize2,
7
9
  PlusSquare,
8
- Rows3,
9
10
  XSquare,
10
11
  } from "lucide-react";
11
12
  import type { JSX } from "react";
@@ -17,9 +18,12 @@ import {
17
18
  } from "#core/features/shortcuts/components/shortcut-card";
18
19
  import {
19
20
  ALT_KEY_LABELS,
21
+ ARROW_KEY_LABELS,
22
+ ARROW_KEYS_LABEL,
20
23
  CREATE_SHORTCUT_KEYS,
24
+ CTRL_KEY_LABELS,
21
25
  EXPAND_SHORTCUT_KEY,
22
- TAB_SHORTCUT_DIGITS,
26
+ SUBMIT_SHORTCUT_KEY,
23
27
  } from "#core/hooks/use-shortcut";
24
28
 
25
29
  /**
@@ -45,10 +49,10 @@ const SHORTCUTS: Shortcut[] = [
45
49
  name: "shortcuts.tabs.name",
46
50
  // `Alt` e `⌥` são a mesma tecla: viram duas combinações e o card escreve
47
51
  // "ou" entre elas, em vez de a diferença virar nota de rodapé.
48
- combos: ALT_KEY_LABELS.map((alt) => [alt, TAB_SHORTCUT_DIGITS]),
52
+ combos: ALT_KEY_LABELS.map((alt) => [alt, ARROW_KEYS_LABEL]),
49
53
  where: "shortcuts.tabs.where",
50
54
  caveat: "shortcuts.tabs.caveat",
51
- icon: Rows3,
55
+ icon: ArrowLeftRight,
52
56
  },
53
57
  {
54
58
  name: "shortcuts.expand.name",
@@ -57,9 +61,23 @@ const SHORTCUTS: Shortcut[] = [
57
61
  caveat: "shortcuts.expand.caveat",
58
62
  icon: Maximize2,
59
63
  },
64
+ {
65
+ name: "shortcuts.period.name",
66
+ // Uma seta para cada lado: são dois atalhos, e o card escreve "ou" entre
67
+ // eles como faz com as duas grafias do Alt.
68
+ combos: ARROW_KEY_LABELS.map((key) => [key]),
69
+ where: "shortcuts.period.where",
70
+ caveat: "shortcuts.period.caveat",
71
+ icon: CalendarRange,
72
+ },
60
73
  {
61
74
  name: "shortcuts.submit.name",
62
- combos: [["Enter"]],
75
+ // O Enter sozinho é o submit do HTML; com Ctrl (⌘ no Mac) é o nosso, que
76
+ // vale de qualquer campo do modal.
77
+ combos: [
78
+ [SUBMIT_SHORTCUT_KEY],
79
+ ...CTRL_KEY_LABELS.map((ctrl) => [ctrl, SUBMIT_SHORTCUT_KEY]),
80
+ ],
63
81
  where: "shortcuts.submit.where",
64
82
  caveat: "shortcuts.submit.caveat",
65
83
  icon: CornerDownLeft,
@@ -8,6 +8,49 @@ const DOUBLE_PRESS_WINDOW_MS = 500;
8
8
  /** Onde a tecla é texto, e não comando. */
9
9
  const TYPING_TAGS = new Set(["INPUT", "TEXTAREA", "SELECT"]);
10
10
 
11
+ /** As teclas que andam dentro de um widget — e que ele espera receber. */
12
+ const NAVIGATION_KEYS = new Set([
13
+ "ArrowLeft",
14
+ "ArrowRight",
15
+ "ArrowUp",
16
+ "ArrowDown",
17
+ "Home",
18
+ "End",
19
+ ]);
20
+
21
+ /**
22
+ * Os widgets que usam as setas para andar por dentro de si.
23
+ *
24
+ * Menus, listas de opções, rádios, sliders e grades seguem o padrão ARIA de
25
+ * foco itinerante: a seta troca o item focado. Com o foco num deles, a tecla
26
+ * é do widget — um atalho global de seta que também disparasse andaria o
27
+ * menu **e** o período no mesmo aperto.
28
+ *
29
+ * A aba **não** está aqui de propósito: o `Tabs` do core desliga a seta
30
+ * solta (a aba anda com `Alt` + seta), para `←`/`→` ter um dono só na tela.
31
+ *
32
+ * Por `closest`, e não pelo alvo: a opção do select é um item dentro do
33
+ * `listbox`, e a célula é um botão dentro do `grid`.
34
+ */
35
+ const NAVIGATION_WIDGET_SELECTOR = [
36
+ "menu",
37
+ "menubar",
38
+ "menuitem",
39
+ "menuitemcheckbox",
40
+ "menuitemradio",
41
+ "listbox",
42
+ "option",
43
+ "radiogroup",
44
+ "radio",
45
+ "slider",
46
+ "spinbutton",
47
+ "grid",
48
+ "tree",
49
+ "treeitem",
50
+ ]
51
+ .map((role) => `[role="${role}"]`)
52
+ .join(", ");
53
+
11
54
  /**
12
55
  * As teclas que abrem o formulário de cadastro, em toda tela que tiver um.
13
56
  *
@@ -47,6 +90,26 @@ export const EXPAND_SHORTCUT_KEY = "Enter";
47
90
  /** Como ele se apresenta ao usuário. */
48
91
  export const EXPAND_SHORTCUT_LABEL = "Alt + Enter";
49
92
 
93
+ /**
94
+ * A tecla que envia o formulário de qualquer lugar do modal, com `Ctrl`.
95
+ *
96
+ * O `Enter` sozinho já envia — é o submit implícito do HTML —, mas só de um
97
+ * campo de texto: num select ele abre a lista, numa caixa de marcar ele marca,
98
+ * num texto longo ele quebra a linha. `Ctrl + Enter` (`⌘ + Enter` no Mac) é
99
+ * a convenção de "enviar mesmo assim" que chat e editor já ensinaram, e não
100
+ * escreve nada em lugar nenhum.
101
+ */
102
+ export const SUBMIT_SHORTCUT_KEY = "Enter";
103
+
104
+ /**
105
+ * O modificador do `Ctrl`, escrito das duas formas que os teclados usam.
106
+ *
107
+ * `Ctrl` e `⌘` **não** são a mesma tecla física, mas são a mesma na cabeça
108
+ * de quem usa — e o hook aceita as duas por `ctrl: true`. A tela de atalhos
109
+ * mostra as duas pelo mesmo motivo do `Alt`/`⌥`.
110
+ */
111
+ export const CTRL_KEY_LABELS = ["Ctrl", "⌘"] as const;
112
+
50
113
  /**
51
114
  * O modificador do `Alt`, escrito das duas formas que os teclados usam.
52
115
  *
@@ -57,15 +120,24 @@ export const EXPAND_SHORTCUT_LABEL = "Alt + Enter";
57
120
  export const ALT_KEY_LABELS = ["Alt", "⌥"] as const;
58
121
 
59
122
  /**
60
- * Quantas abas o atalho numera.
123
+ * As setas as duas teclas de "anterior" e "seguinte" do sistema.
124
+ *
125
+ * Soltas, andam de período (mês, ano) na tela que tem um seletor; com `Alt`,
126
+ * andam de aba. A tecla é a mesma e o modificador diz o quê: a seta solta não
127
+ * escreve nada e não é atalho de navegador, e o que ela faz por conta própria
128
+ * — andar dentro de menus e listas — o hook respeita cedendo a vez.
61
129
  *
62
- * Nove porque `Alt + 0` não segue a sequência, e porque ninguém conta abas além
63
- * disso sem olhar.
130
+ * `Alt + ←` é o "voltar" do navegador no Windows; o hook impede o padrão
131
+ * quando o atalho dispara, e fora de uma tela com abas a tecla segue livre.
64
132
  */
65
- export const MAX_TAB_SHORTCUTS = 9;
133
+ export const PREVIOUS_ARROW_KEY = "ArrowLeft";
134
+ export const NEXT_ARROW_KEY = "ArrowRight";
66
135
 
67
- /** Como o atalho de abas se apresenta: `Alt` + `1…9`. */
68
- export const TAB_SHORTCUT_DIGITS = `1…${MAX_TAB_SHORTCUTS}`;
136
+ /** Como as setas se apresentam, uma para cada lado. */
137
+ export const ARROW_KEY_LABELS = ["←", "→"] as const;
138
+
139
+ /** As duas setas num rótulo só, para o atalho que usa qualquer uma delas. */
140
+ export const ARROW_KEYS_LABEL = "← →";
69
141
 
70
142
  export interface ShortcutOptions {
71
143
  /**
@@ -162,6 +234,9 @@ export interface ShortcutOptions {
162
234
  * ele — senão o atalho abriria um segundo por cima do primeiro.
163
235
  * - **Modificador não pedido cancela.** `Ctrl`/`Cmd`/`Alt` + a tecla costuma ser
164
236
  * atalho do navegador ou do sistema; quem quiser um deles declara `ctrl`.
237
+ * - **Seta com o foco num widget que anda por setas é dele.** Menu, lista,
238
+ * rádio, slider e grade trocam o item focado com `←`/`→`; o atalho global
239
+ * de seta não dispara ali.
165
240
  *
166
241
  * ```tsx
167
242
  * // Na listagem: duas batidas abrem o cadastro.
@@ -244,6 +319,9 @@ export function useShortcut(
244
319
  if (typing && !allowWhileTyping) {
245
320
  return;
246
321
  }
322
+ if (isNavigatingWidget(event)) {
323
+ return;
324
+ }
247
325
  if (!allowInDialog && hasOpenDialog()) {
248
326
  return;
249
327
  }
@@ -304,6 +382,20 @@ const isTyping = (target: EventTarget | null): boolean => {
304
382
  return TYPING_TAGS.has(target.tagName) || target.isContentEditable;
305
383
  };
306
384
 
385
+ /**
386
+ * A tecla é de navegação e o foco está num widget que a consome?
387
+ *
388
+ * Checado aqui, e não por `event.defaultPrevented`, porque o hook ouve na
389
+ * fase de captura: quando ele vê a tecla, o widget ainda não a tratou.
390
+ */
391
+ const isNavigatingWidget = (event: KeyboardEvent): boolean => {
392
+ if (!NAVIGATION_KEYS.has(event.key) || !(event.target instanceof HTMLElement)) {
393
+ return false;
394
+ }
395
+
396
+ return event.target.closest(NAVIGATION_WIDGET_SELECTOR) !== null;
397
+ };
398
+
307
399
  /**
308
400
  * Há um modal aberto?
309
401
  *
@@ -145,7 +145,7 @@ export const en: Messages = {
145
145
  tabs: {
146
146
  name: "Switch tab",
147
147
  where:
148
- "On any screen with tabs, the number opens the tab in that position1 is the first.",
148
+ "On any screen with tabs: the left arrow goes to the previous tab, the right one to the next and from the last it wraps to the first.",
149
149
  caveat: "Does not fire inside a text field or with a modal open",
150
150
  },
151
151
  expand: {
@@ -156,16 +156,24 @@ export const en: Messages = {
156
156
  },
157
157
  submit: {
158
158
  name: "Submit the form",
159
- where: "With a create dialog open, saves without clicking.",
160
- caveat: "Inside a long text field, Enter adds a line break",
159
+ where:
160
+ "With a create dialog open, saves without clicking. Enter alone submits from a text field; with Ctrl it submits from anywhere in the dialog — a select, a checkbox or a long text, where Enter alone opens, toggles or adds a line break.",
161
+ caveat: "With the Save button disabled, neither one submits",
161
162
  },
162
163
  close: {
163
164
  name: "Close the dialog",
164
165
  where: "Closes the open dialog, same as the close button.",
165
166
  caveat: "A changed form asks for confirmation before leaving",
166
167
  },
168
+ period: {
169
+ name: "Change the period",
170
+ where:
171
+ "On a screen with a month or year selector at the top: the left arrow goes back one period, the right one moves forward.",
172
+ caveat:
173
+ "Does not fire inside a text field, with a dialog open, or with focus on a tab, list or calendar — there the arrow is theirs",
174
+ },
167
175
  footer:
168
- "Two keys open the create form because keyboards differ: the backslash moves around depending on the layout, while the number is always in the same place.",
176
+ "Two keys open the create form because keyboards differ: the backslash moves around depending on the layout, while the number is always in the same place. The arrows do two things and Alt tells which: alone they change the period, with Alt they switch tabs.",
169
177
  },
170
178
  export: {
171
179
  label: "Export",
@@ -306,6 +314,8 @@ export const en: Messages = {
306
314
  no: "No",
307
315
  actions: "Actions",
308
316
  shortcutHint: "Shortcut: {keys}",
317
+ previousPeriod: "Previous period",
318
+ nextPeriod: "Next period",
309
319
  loading: "Loading...",
310
320
  description: "Description",
311
321
  language: "Language",
@@ -147,7 +147,7 @@ export const pt = {
147
147
  tabs: {
148
148
  name: "Trocar de aba",
149
149
  where:
150
- "Em qualquer tela com abas, o número abre a aba naquela posição1 é a primeira.",
150
+ "Em qualquer tela com abas: a seta para a esquerda vai para a aba anterior, a da direita para a seguinte e da última volta para a primeira.",
151
151
  caveat: "Não dispara dentro de um campo de texto nem com um modal aberto",
152
152
  },
153
153
  expand: {
@@ -158,16 +158,24 @@ export const pt = {
158
158
  },
159
159
  submit: {
160
160
  name: "Enviar o formulário",
161
- where: "Com um modal de cadastro aberto, salva sem precisar clicar.",
162
- caveat: "Dentro de um campo de texto longo, o Enter quebra a linha",
161
+ where:
162
+ "Com um modal de cadastro aberto, salva sem precisar clicar. O Enter sozinho envia a partir de um campo de texto; com Ctrl envia de qualquer lugar do modal — de um select, de uma caixa de marcar ou de um texto longo, onde o Enter sozinho abre, marca ou quebra a linha.",
163
+ caveat: "Com o botão Salvar desativado, nenhum dos dois envia",
163
164
  },
164
165
  close: {
165
166
  name: "Fechar o modal",
166
167
  where: "Fecha o modal aberto, como o botão de fechar.",
167
168
  caveat: "Um formulário alterado pede confirmação antes de sair",
168
169
  },
170
+ period: {
171
+ name: "Mudar o período",
172
+ where:
173
+ "Na tela que tem um seletor de mês ou ano no topo: a seta para a esquerda volta um período, a da direita avança.",
174
+ caveat:
175
+ "Não dispara dentro de um campo de texto, com um modal aberto, nem com o foco numa aba, lista ou calendário — ali a seta é deles",
176
+ },
169
177
  footer:
170
- "Duas teclas para abrir o cadastro porque o teclado não é um só: a barra invertida muda de lugar conforme o layout, e o número está sempre na mesma posição. Já o atalho de ampliar leva Alt porque precisa valer dentro dos campos — e uma tecla que escreve não pode servir de comando ali.",
178
+ "Duas teclas para abrir o cadastro porque o teclado não é um só: a barra invertida muda de lugar conforme o layout, e o número está sempre na mesma posição. As setas fazem duas coisas e o Alt diz qual: soltas andam de período, com Alt andam de aba. Já o atalho de ampliar leva Alt porque precisa valer dentro dos campos — e uma tecla que escreve não pode servir de comando ali.",
171
179
  },
172
180
  export: {
173
181
  label: "Exportar",
@@ -309,6 +317,8 @@ export const pt = {
309
317
  no: "Não",
310
318
  actions: "Ações",
311
319
  shortcutHint: "Atalho: {keys}",
320
+ previousPeriod: "Período anterior",
321
+ nextPeriod: "Próximo período",
312
322
  loading: "Carregando...",
313
323
  description: "Descrição",
314
324
  language: "Idioma",
package/src/index.ts CHANGED
@@ -113,12 +113,16 @@ export type { RunOptions, UseRequestResult } from "#core/hooks/use-request";
113
113
  export type { ShortcutOptions } from "#core/hooks/use-shortcut";
114
114
  export {
115
115
  ALT_KEY_LABELS,
116
+ ARROW_KEY_LABELS,
117
+ ARROW_KEYS_LABEL,
116
118
  CREATE_SHORTCUT_KEYS,
117
119
  CREATE_SHORTCUT_LABEL,
120
+ CTRL_KEY_LABELS,
118
121
  EXPAND_SHORTCUT_KEY,
119
122
  EXPAND_SHORTCUT_LABEL,
120
- MAX_TAB_SHORTCUTS,
121
- TAB_SHORTCUT_DIGITS,
123
+ NEXT_ARROW_KEY,
124
+ PREVIOUS_ARROW_KEY,
125
+ SUBMIT_SHORTCUT_KEY,
122
126
  useShortcut,
123
127
  } from "#core/hooks/use-shortcut";
124
128
  export type { UseTabStateOptions, UseTabStateResult } from "#core/hooks/use-tab-state";