rl-core-front 0.18.10 → 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 +1 -1
- package/src/_services/api/schema.d.ts +89 -9
- package/src/_services/auth/index.ts +2 -3
- package/src/_utils/upload-url.ts +5 -0
- package/src/components/app-shell.tsx +3 -1
- package/src/components/ui/dialog.tsx +60 -1
- package/src/components/ui/help-hint.tsx +36 -0
- package/src/components/ui/hover-tip.tsx +81 -55
- package/src/components/ui/image-upload.tsx +8 -4
- package/src/components/ui/index.ts +2 -0
- package/src/components/ui/period-nav.tsx +104 -0
- package/src/components/ui/tab-shortcuts.tsx +34 -60
- package/src/components/ui/tabs.tsx +21 -1
- package/src/features/shortcuts/shortcuts-screen.tsx +23 -5
- package/src/hooks/use-shortcut.ts +98 -6
- package/src/i18n/messages/en.ts +14 -4
- package/src/i18n/messages/pt.ts +14 -4
- package/src/index.ts +6 -2
- package/src/middleware.ts +111 -1
package/package.json
CHANGED
|
@@ -244,17 +244,17 @@ export interface paths {
|
|
|
244
244
|
patch?: never;
|
|
245
245
|
trace?: never;
|
|
246
246
|
};
|
|
247
|
-
"/auth/password-token/
|
|
247
|
+
"/auth/password-token/inspect": {
|
|
248
248
|
parameters: {
|
|
249
249
|
query?: never;
|
|
250
250
|
header?: never;
|
|
251
251
|
path?: never;
|
|
252
252
|
cookie?: never;
|
|
253
253
|
};
|
|
254
|
-
|
|
255
|
-
get: operations["AuthController_inspectPasswordToken"];
|
|
254
|
+
get?: never;
|
|
256
255
|
put?: never;
|
|
257
|
-
|
|
256
|
+
/** Verifica a validade do link de senha sem consumi-lo */
|
|
257
|
+
post: operations["AuthController_inspectPasswordToken"];
|
|
258
258
|
delete?: never;
|
|
259
259
|
options?: never;
|
|
260
260
|
head?: never;
|
|
@@ -838,6 +838,38 @@ export interface paths {
|
|
|
838
838
|
patch: operations["NotificationsController_markAsRead"];
|
|
839
839
|
trace?: never;
|
|
840
840
|
};
|
|
841
|
+
"/notifications/read": {
|
|
842
|
+
parameters: {
|
|
843
|
+
query?: never;
|
|
844
|
+
header?: never;
|
|
845
|
+
path?: never;
|
|
846
|
+
cookie?: never;
|
|
847
|
+
};
|
|
848
|
+
get?: never;
|
|
849
|
+
put?: never;
|
|
850
|
+
post?: never;
|
|
851
|
+
delete: operations["NotificationsController_dismissRead"];
|
|
852
|
+
options?: never;
|
|
853
|
+
head?: never;
|
|
854
|
+
patch?: never;
|
|
855
|
+
trace?: never;
|
|
856
|
+
};
|
|
857
|
+
"/notifications/{id}": {
|
|
858
|
+
parameters: {
|
|
859
|
+
query?: never;
|
|
860
|
+
header?: never;
|
|
861
|
+
path?: never;
|
|
862
|
+
cookie?: never;
|
|
863
|
+
};
|
|
864
|
+
get?: never;
|
|
865
|
+
put?: never;
|
|
866
|
+
post?: never;
|
|
867
|
+
delete: operations["NotificationsController_dismiss"];
|
|
868
|
+
options?: never;
|
|
869
|
+
head?: never;
|
|
870
|
+
patch?: never;
|
|
871
|
+
trace?: never;
|
|
872
|
+
};
|
|
841
873
|
"/exports/{handler}": {
|
|
842
874
|
parameters: {
|
|
843
875
|
query?: never;
|
|
@@ -1148,6 +1180,9 @@ export interface components {
|
|
|
1148
1180
|
ForgotPasswordCommand: {
|
|
1149
1181
|
email: string;
|
|
1150
1182
|
};
|
|
1183
|
+
InspectPasswordTokenCommand: {
|
|
1184
|
+
token: string;
|
|
1185
|
+
};
|
|
1151
1186
|
ResetPasswordCommand: {
|
|
1152
1187
|
token: string;
|
|
1153
1188
|
newPassword: string;
|
|
@@ -1588,6 +1623,13 @@ export interface components {
|
|
|
1588
1623
|
sortBy?: string;
|
|
1589
1624
|
/** @enum {string} */
|
|
1590
1625
|
sortDir?: "ASC" | "DESC";
|
|
1626
|
+
/**
|
|
1627
|
+
* @description Parâmetros do documento, validados pelo relatório pedido
|
|
1628
|
+
* @example {
|
|
1629
|
+
* "referenceMonth": "2026-09"
|
|
1630
|
+
* }
|
|
1631
|
+
*/
|
|
1632
|
+
params?: Record<string, never>;
|
|
1591
1633
|
};
|
|
1592
1634
|
ExportAcceptedResponse: {
|
|
1593
1635
|
/** @example 184 */
|
|
@@ -2038,12 +2080,14 @@ export interface operations {
|
|
|
2038
2080
|
parameters: {
|
|
2039
2081
|
query?: never;
|
|
2040
2082
|
header?: never;
|
|
2041
|
-
path
|
|
2042
|
-
token: string;
|
|
2043
|
-
};
|
|
2083
|
+
path?: never;
|
|
2044
2084
|
cookie?: never;
|
|
2045
2085
|
};
|
|
2046
|
-
requestBody
|
|
2086
|
+
requestBody: {
|
|
2087
|
+
content: {
|
|
2088
|
+
"application/json": components["schemas"]["InspectPasswordTokenCommand"];
|
|
2089
|
+
};
|
|
2090
|
+
};
|
|
2047
2091
|
responses: {
|
|
2048
2092
|
200: {
|
|
2049
2093
|
headers: {
|
|
@@ -3338,6 +3382,42 @@ export interface operations {
|
|
|
3338
3382
|
};
|
|
3339
3383
|
};
|
|
3340
3384
|
};
|
|
3385
|
+
NotificationsController_dismissRead: {
|
|
3386
|
+
parameters: {
|
|
3387
|
+
query?: never;
|
|
3388
|
+
header?: never;
|
|
3389
|
+
path?: never;
|
|
3390
|
+
cookie?: never;
|
|
3391
|
+
};
|
|
3392
|
+
requestBody?: never;
|
|
3393
|
+
responses: {
|
|
3394
|
+
204: {
|
|
3395
|
+
headers: {
|
|
3396
|
+
[name: string]: unknown;
|
|
3397
|
+
};
|
|
3398
|
+
content?: never;
|
|
3399
|
+
};
|
|
3400
|
+
};
|
|
3401
|
+
};
|
|
3402
|
+
NotificationsController_dismiss: {
|
|
3403
|
+
parameters: {
|
|
3404
|
+
query?: never;
|
|
3405
|
+
header?: never;
|
|
3406
|
+
path: {
|
|
3407
|
+
id: string;
|
|
3408
|
+
};
|
|
3409
|
+
cookie?: never;
|
|
3410
|
+
};
|
|
3411
|
+
requestBody?: never;
|
|
3412
|
+
responses: {
|
|
3413
|
+
204: {
|
|
3414
|
+
headers: {
|
|
3415
|
+
[name: string]: unknown;
|
|
3416
|
+
};
|
|
3417
|
+
content?: never;
|
|
3418
|
+
};
|
|
3419
|
+
};
|
|
3420
|
+
};
|
|
3341
3421
|
ExportController_request: {
|
|
3342
3422
|
parameters: {
|
|
3343
3423
|
query?: never;
|
|
@@ -3395,7 +3475,7 @@ export interface operations {
|
|
|
3395
3475
|
};
|
|
3396
3476
|
requestBody?: never;
|
|
3397
3477
|
responses: {
|
|
3398
|
-
/** @description
|
|
3478
|
+
/** @description O arquivo gerado */
|
|
3399
3479
|
200: {
|
|
3400
3480
|
headers: {
|
|
3401
3481
|
[name: string]: unknown;
|
|
@@ -84,10 +84,9 @@ export const authService = {
|
|
|
84
84
|
|
|
85
85
|
/** Verifica o link de senha antes de exibir o formulário (não consome o token). */
|
|
86
86
|
inspectPasswordToken(token: string): Promise<PasswordTokenCheck> {
|
|
87
|
+
// No corpo, não na URL: token em path vai parar em log de proxy.
|
|
87
88
|
return api
|
|
88
|
-
.
|
|
89
|
-
`/auth/password-token/${encodeURIComponent(token)}`,
|
|
90
|
-
)
|
|
89
|
+
.post<PasswordTokenCheck>("/auth/password-token/inspect", { token })
|
|
91
90
|
.then((r) => r.data);
|
|
92
91
|
},
|
|
93
92
|
|
package/src/_utils/upload-url.ts
CHANGED
|
@@ -18,6 +18,11 @@ const API_ORIGIN = (process.env.NEXT_PUBLIC_API_BASE_URL ?? "").replace(
|
|
|
18
18
|
* avatar gravado por versões anteriores e de qualquer endereço externo que
|
|
19
19
|
* ainda esteja em base.
|
|
20
20
|
*/
|
|
21
|
+
/** Imagem ainda não confirmada por um salvar — vive em `/uploads/<pasta>/pending/`. */
|
|
22
|
+
export function isPendingImagePath(path?: string | null): boolean {
|
|
23
|
+
return /^\/uploads\/[^/]+\/pending\//.test(path ?? "");
|
|
24
|
+
}
|
|
25
|
+
|
|
21
26
|
export function uploadUrlOf(path?: string | null): string | undefined {
|
|
22
27
|
if (!path) {
|
|
23
28
|
return undefined;
|
|
@@ -177,7 +177,9 @@ const ADMIN_CHILDREN: NavItem[] = [
|
|
|
177
177
|
perm: anyOrTeam("users:read"),
|
|
178
178
|
},
|
|
179
179
|
{ key: "nav.rbac", href: "/rbac", icon: ShieldCheck, perm: "roles:read:any" },
|
|
180
|
-
|
|
180
|
+
// Gerente vê o log da própria equipe — a rota poda pelo mesmo alcance da
|
|
181
|
+
// listagem de usuários.
|
|
182
|
+
{ key: "nav.logs", href: "/logs", icon: ScrollText, perm: anyOrTeam("audit:read") },
|
|
181
183
|
// Logo abaixo do log de requisições, que é o vizinho natural: os dois contam
|
|
182
184
|
// o que aconteceu no sistema, um por chamada e outro por alteração de dado.
|
|
183
185
|
// Restrito a administradores — só quem tem audit:read-trail:any (só Super
|
|
@@ -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 {
|
|
4
|
-
import { useState } from "react";
|
|
5
|
-
|
|
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
|
-
/**
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
const
|
|
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
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
33
|
+
* Existe porque o `title` do HTML só 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
|
-
*
|
|
32
|
-
*
|
|
33
|
-
* e
|
|
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
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
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 [
|
|
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 = (
|
|
47
|
-
|
|
55
|
+
const show = (): void => {
|
|
56
|
+
if (closing.current) {
|
|
57
|
+
clearTimeout(closing.current);
|
|
58
|
+
closing.current = null;
|
|
59
|
+
}
|
|
60
|
+
setOpen(true);
|
|
61
|
+
};
|
|
48
62
|
|
|
49
|
-
|
|
63
|
+
const hide = (): void => {
|
|
64
|
+
closing.current = setTimeout(() => setOpen(false), CLOSE_GRACE_MS);
|
|
50
65
|
};
|
|
51
66
|
|
|
52
|
-
|
|
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
|
-
<
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
};
|
|
@@ -5,7 +5,7 @@ import type { ChangeEvent, ClipboardEvent, DragEvent, JSX } from "react";
|
|
|
5
5
|
import { useRef, useState } from "react";
|
|
6
6
|
|
|
7
7
|
import { imagesService } from "#core/_services/images";
|
|
8
|
-
import { uploadUrlOf } from "#core/_utils/upload-url";
|
|
8
|
+
import { isPendingImagePath, uploadUrlOf } from "#core/_utils/upload-url";
|
|
9
9
|
import { Button } from "#core/components/ui/button";
|
|
10
10
|
import { Input } from "#core/components/ui/input";
|
|
11
11
|
import { Spinner } from "#core/components/ui/spinner";
|
|
@@ -102,9 +102,13 @@ export function ImageUpload({
|
|
|
102
102
|
return;
|
|
103
103
|
}
|
|
104
104
|
|
|
105
|
-
//
|
|
106
|
-
//
|
|
107
|
-
|
|
105
|
+
// Só a pendente sai do disco por aqui — é o upload desta sessão que ninguém
|
|
106
|
+
// referencia. A definitiva pertence ao registro: quem a apaga é o salvar
|
|
107
|
+
// (`discard()` no service da feature), e a API recusa o caminho por esta
|
|
108
|
+
// rota de propósito.
|
|
109
|
+
if (isPendingImagePath(value)) {
|
|
110
|
+
await imagesService.remove(profile, value).catch(() => undefined);
|
|
111
|
+
}
|
|
108
112
|
onChange(null);
|
|
109
113
|
clearError();
|
|
110
114
|
};
|
|
@@ -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 {
|
|
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
|
-
*
|
|
20
|
+
* `Alt` + `←`/`→` troca de aba. Não desenha nada.
|
|
15
21
|
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
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 só aparece na
|
|
24
|
+
* tela onde os dois existem. `Alt` + número, que já 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
|
-
*
|
|
31
|
-
*
|
|
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
|
+
* Dá 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
|
|
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
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
|
|
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,
|
|
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:
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
-
*
|
|
63
|
-
*
|
|
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
|
|
133
|
+
export const PREVIOUS_ARROW_KEY = "ArrowLeft";
|
|
134
|
+
export const NEXT_ARROW_KEY = "ArrowRight";
|
|
66
135
|
|
|
67
|
-
/** Como
|
|
68
|
-
export const
|
|
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
|
*
|
package/src/i18n/messages/en.ts
CHANGED
|
@@ -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
|
|
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:
|
|
160
|
-
|
|
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",
|
package/src/i18n/messages/pt.ts
CHANGED
|
@@ -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
|
|
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:
|
|
162
|
-
|
|
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
|
-
|
|
121
|
-
|
|
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";
|
package/src/middleware.ts
CHANGED
|
@@ -7,9 +7,119 @@ import { NextRequest, NextResponse } from "next/server";
|
|
|
7
7
|
*
|
|
8
8
|
* Observação: a validação forte do token é feita no backend; aqui é só
|
|
9
9
|
* o "portão" de roteamento (UX), evitando flicker de telas privadas.
|
|
10
|
+
*
|
|
11
|
+
* Também é aqui que a Content-Security-Policy nasce, porque o nonce precisa
|
|
12
|
+
* ser novo a cada requisição e chegar ao `layout.tsx` antes de a página
|
|
13
|
+
* renderizar — ver `buildContentSecurityPolicy`.
|
|
10
14
|
*/
|
|
11
15
|
const PUBLIC_PATHS = ["/login", "/forgot-password", "/reset-password"];
|
|
12
16
|
|
|
17
|
+
/** Cabeçalho por onde o nonce chega ao `layout.tsx` (`headers().get("x-nonce")`). */
|
|
18
|
+
export const NONCE_HEADER = "x-nonce";
|
|
19
|
+
|
|
20
|
+
export interface ContentSecurityPolicyOptions {
|
|
21
|
+
/** Nonce desta requisição — vai nos scripts inline do Next e no de tema do layout. */
|
|
22
|
+
nonce: string;
|
|
23
|
+
/** Origens extras de `connect-src` (outra API, um serviço de terceiros). */
|
|
24
|
+
extraConnectSrc?: string[];
|
|
25
|
+
/** Origens extras de `img-src` (CDN de imagens, S3). */
|
|
26
|
+
extraImgSrc?: string[];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Os mesmos padrões do `axios.factory` e do `socket-context`: sem a variável, a
|
|
31
|
+
* CSP precisa liberar exatamente para onde o cliente vai cair, senão o navegador
|
|
32
|
+
* bloqueia toda chamada.
|
|
33
|
+
*/
|
|
34
|
+
const DEFAULT_API_BASE_URL = "http://localhost:3108/api/v1";
|
|
35
|
+
const DEFAULT_SOCKET_URL = "http://localhost:3108";
|
|
36
|
+
|
|
37
|
+
const originOf = (url: string): string | null => {
|
|
38
|
+
try {
|
|
39
|
+
return new URL(url).origin;
|
|
40
|
+
} catch {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/** Origem da API (`NEXT_PUBLIC_API_BASE_URL`), sem prefixo — é de lá que vêm avatar e download. */
|
|
46
|
+
const apiOrigin = (): string | null =>
|
|
47
|
+
originOf(process.env.NEXT_PUBLIC_API_BASE_URL ?? DEFAULT_API_BASE_URL);
|
|
48
|
+
|
|
49
|
+
/** Origem do socket (`NEXT_PUBLIC_SOCKET_URL`) — costuma ser a da API, mas pode divergir. */
|
|
50
|
+
const socketOrigin = (): string | null =>
|
|
51
|
+
originOf(process.env.NEXT_PUBLIC_SOCKET_URL ?? DEFAULT_SOCKET_URL);
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* A CSP do core.
|
|
55
|
+
*
|
|
56
|
+
* Defesa em profundidade: hoje não há sink de XSS na interface, e a CSP é o
|
|
57
|
+
* que limita o estrago de um que apareça num componente de terceiro. Script só
|
|
58
|
+
* com nonce (`strict-dynamic` deixa o Next carregar os chunks dele); `img` e
|
|
59
|
+
* `connect` liberam a origem da API, porque avatar, axios e socket.io falam
|
|
60
|
+
* com ela. `style` aceita inline porque Tailwind e Radix escrevem `style=`
|
|
61
|
+
* direto no elemento — bloquear isso quebraria todo popover e menu.
|
|
62
|
+
*
|
|
63
|
+
* `unsafe-eval` só em desenvolvimento: o React o usa para reconstruir stack
|
|
64
|
+
* de erro do servidor no navegador; em produção nem React nem Next o usam.
|
|
65
|
+
*
|
|
66
|
+
* Exportada para o projeto que precisar de mais origens montar a própria: chama
|
|
67
|
+
* esta com `extraConnectSrc`/`extraImgSrc` dentro do middleware dele.
|
|
68
|
+
*/
|
|
69
|
+
export const buildContentSecurityPolicy = ({
|
|
70
|
+
nonce,
|
|
71
|
+
extraConnectSrc = [],
|
|
72
|
+
extraImgSrc = [],
|
|
73
|
+
}: ContentSecurityPolicyOptions): string => {
|
|
74
|
+
const api = apiOrigin();
|
|
75
|
+
const socket = socketOrigin();
|
|
76
|
+
const isDev = process.env.NODE_ENV === "development";
|
|
77
|
+
// O socket.io começa por polling HTTP e sobe para WebSocket: as duas formas.
|
|
78
|
+
const connect = [
|
|
79
|
+
"'self'",
|
|
80
|
+
api,
|
|
81
|
+
socket,
|
|
82
|
+
socket?.replace(/^http/, "ws") ?? null,
|
|
83
|
+
...extraConnectSrc,
|
|
84
|
+
].filter(Boolean);
|
|
85
|
+
const img = ["'self'", "blob:", "data:", api, ...extraImgSrc].filter(Boolean);
|
|
86
|
+
|
|
87
|
+
return [
|
|
88
|
+
"default-src 'self'",
|
|
89
|
+
`script-src 'self' 'nonce-${nonce}' 'strict-dynamic'${isDev ? " 'unsafe-eval'" : ""}`,
|
|
90
|
+
"style-src 'self' 'unsafe-inline'",
|
|
91
|
+
`img-src ${img.join(" ")}`,
|
|
92
|
+
"font-src 'self' data:",
|
|
93
|
+
`connect-src ${connect.join(" ")}`,
|
|
94
|
+
"object-src 'none'",
|
|
95
|
+
"base-uri 'self'",
|
|
96
|
+
"form-action 'self'",
|
|
97
|
+
"frame-ancestors 'none'",
|
|
98
|
+
].join("; ");
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Devolve a resposta "segue em frente" já com o nonce e a CSP nos dois lados:
|
|
103
|
+
* no request (é de onde o Next lê o nonce para os scripts dele) e no response.
|
|
104
|
+
*/
|
|
105
|
+
export const nextWithSecurityHeaders = (
|
|
106
|
+
req: NextRequest,
|
|
107
|
+
options: Omit<ContentSecurityPolicyOptions, "nonce"> = {},
|
|
108
|
+
): NextResponse => {
|
|
109
|
+
const nonce = Buffer.from(crypto.randomUUID()).toString("base64");
|
|
110
|
+
const csp = buildContentSecurityPolicy({ nonce, ...options });
|
|
111
|
+
|
|
112
|
+
const requestHeaders = new Headers(req.headers);
|
|
113
|
+
requestHeaders.set(NONCE_HEADER, nonce);
|
|
114
|
+
requestHeaders.set("Content-Security-Policy", csp);
|
|
115
|
+
|
|
116
|
+
const response = NextResponse.next({ request: { headers: requestHeaders } });
|
|
117
|
+
response.headers.set("Content-Security-Policy", csp);
|
|
118
|
+
response.headers.set("X-Content-Type-Options", "nosniff");
|
|
119
|
+
response.headers.set("Referrer-Policy", "strict-origin-when-cross-origin");
|
|
120
|
+
return response;
|
|
121
|
+
};
|
|
122
|
+
|
|
13
123
|
export function middleware(req: NextRequest): NextResponse<unknown> {
|
|
14
124
|
const { pathname } = req.nextUrl;
|
|
15
125
|
const hasSession =
|
|
@@ -34,7 +144,7 @@ export function middleware(req: NextRequest): NextResponse<unknown> {
|
|
|
34
144
|
return NextResponse.redirect(url);
|
|
35
145
|
}
|
|
36
146
|
|
|
37
|
-
return
|
|
147
|
+
return nextWithSecurityHeaders(req);
|
|
38
148
|
}
|
|
39
149
|
|
|
40
150
|
export const config = {
|