rl-core-front 0.14.3 → 0.15.0

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.
@@ -0,0 +1,267 @@
1
+ "use client";
2
+
3
+ import { useEffect, useRef } from "react";
4
+
5
+ /** Janela entre as duas batidas, em milissegundos. */
6
+ const DOUBLE_PRESS_WINDOW_MS = 500;
7
+
8
+ /** Onde a tecla é texto, e não comando. */
9
+ const TYPING_TAGS = new Set(["INPUT", "TEXTAREA", "SELECT"]);
10
+
11
+ /**
12
+ * As teclas que abrem o formulário de cadastro, em toda tela que tiver um.
13
+ *
14
+ * Moram aqui, e não em cada tela, porque atalho que muda de tela para tela não
15
+ * é atalho — é armadilha.
16
+ *
17
+ * São **duas** porque teclado não é um só: `\` fica em lugares diferentes
18
+ * conforme o layout (e em alguns exige combinação), enquanto `1` está na mesma
19
+ * posição em qualquer um. Quem tem a barra usa a barra; quem não tem, usa o
20
+ * número. Nenhuma das duas compete com atalho de navegador.
21
+ */
22
+ export const CREATE_SHORTCUT_KEYS = ["1", "\\"] as const;
23
+
24
+ /** Como o atalho se apresenta ao usuário. */
25
+ export const CREATE_SHORTCUT_LABEL = "11";
26
+
27
+ /**
28
+ * A combinação que amplia e restaura o modal aberto: `Alt` + `Enter`.
29
+ *
30
+ * Foram descartadas, nesta ordem e por estes motivos:
31
+ *
32
+ * - **`+` sozinho** e **`Shift + E`**: produzem caractere. Dentro de um modal,
33
+ * que é feito de campos, ou o atalho some ou ele dispara junto com a
34
+ * digitação — não há terceira saída.
35
+ * - **`Ctrl + +` e `Ctrl + Shift + +`**: são a mesma combinação física do zoom
36
+ * do navegador (`+` sai de `Shift` + `=` nos teclados brasileiro e
37
+ * americano). O navegador resolve o zoom antes de a página ver a tecla, então
38
+ * a página não tem como vencer.
39
+ *
40
+ * `Alt + Enter` não escreve nada, não é atalho de navegador, e é a convenção de
41
+ * maximizar que as pessoas já conhecem de outros programas. E `Enter` chega
42
+ * como `"Enter"` em qualquer layout — não depende de onde o teclado põe o
43
+ * símbolo.
44
+ */
45
+ export const EXPAND_SHORTCUT_KEY = "Enter";
46
+
47
+ /** Como ele se apresenta ao usuário. */
48
+ export const EXPAND_SHORTCUT_LABEL = "Alt + Enter";
49
+
50
+ export interface ShortcutOptions {
51
+ /**
52
+ * Exige duas batidas seguidas dentro de meio segundo.
53
+ *
54
+ * É o que permite usar uma tecla comum (`\`) sem modificador: sozinha ela é
55
+ * caractere, e duas vezes seguidas fora de um campo de texto não é coisa que
56
+ * alguém digite sem querer.
57
+ */
58
+ double?: boolean;
59
+ /** Desliga o atalho sem tirar o hook da tela — útil enquanto a lista carrega. */
60
+ enabled?: boolean;
61
+ /**
62
+ * Exige `Alt` (ou `Option`, no Mac) junto.
63
+ *
64
+ * Ausente, o `Alt` é recusado — é tecla de menu do sistema, e um atalho de
65
+ * uma tecla só não deve disparar com ela pressionada.
66
+ */
67
+ alt?: boolean;
68
+ /**
69
+ * Exige `Ctrl` (ou `Cmd`, no Mac) junto.
70
+ *
71
+ * Aceita os dois porque é a mesma tecla na cabeça de quem usa — obrigar
72
+ * `Ctrl` no Mac faria o atalho parecer quebrado ali. Ausente, os dois são
73
+ * recusados: sem isso, `Ctrl + tecla` dispararia o atalho de uma tecla só.
74
+ */
75
+ ctrl?: boolean;
76
+ /**
77
+ * O que fazer com o `Shift`: `true` exige, `false` recusa, ausente ignora.
78
+ *
79
+ * **Ignorar é o padrão, e é o certo para a maioria dos casos**, porque o
80
+ * caractere já carrega a informação: `+` no teclado brasileiro sai com
81
+ * `Shift`, no numérico sem — e nos dois `event.key` é `"+"`. Recusar o
82
+ * `Shift` bloquearia metade dos teclados; exigi-lo bloquearia a outra metade.
83
+ *
84
+ * Declare `true` só quando o modificador for parte do atalho (`Shift + E`), e
85
+ * `false` quando a tecla sem ele significar outra coisa.
86
+ */
87
+ shift?: boolean;
88
+ /**
89
+ * Deixa o atalho valer também com o cursor dentro de um campo.
90
+ *
91
+ * O padrão é ceder a vez: numa listagem, `11` tem de poder ser digitado. Mas
92
+ * o atalho que pertence ao modal precisa valer em qualquer lugar dele, como
93
+ * o `Esc` — e o modal é feito de campos.
94
+ *
95
+ * **A tecla não é engolida nesse caso.** `Esc` não produz caractere; `+`
96
+ * produz, e bloqueá-lo impediria de escrever `+55` num telefone. Aqui o
97
+ * atalho dispara *e* o caractere é digitado.
98
+ */
99
+ allowWhileTyping?: boolean;
100
+ /**
101
+ * Deixa o atalho valer **com** um modal aberto.
102
+ *
103
+ * O padrão é o contrário: com um formulário na tela, a tecla é dele. Isto é
104
+ * para o atalho que pertence ao próprio modal — ele nasce e morre junto do
105
+ * modal, então quem monta o hook já é quem tem o direito à tecla.
106
+ */
107
+ allowInDialog?: boolean;
108
+ }
109
+
110
+ /**
111
+ * Atalho de teclado de uma tela.
112
+ *
113
+ * Contraparte do `Esc` que o modal já entende: se uma tecla fecha, outra abre.
114
+ * A tela diz qual tecla e o que fazer; o resto — quando **não** disparar — é
115
+ * daqui:
116
+ *
117
+ * - **Campo de texto ganha**, salvo com `allowWhileTyping`. Digitar `11` num
118
+ * input é digitar `11`, não abrir modal. Vale para `contenteditable` também.
119
+ * - **Modal aberto bloqueia.** Com um formulário na tela, a tecla pertence a
120
+ * ele — senão o atalho abriria um segundo por cima do primeiro.
121
+ * - **Modificador não pedido cancela.** `Ctrl`/`Cmd`/`Alt` + a tecla costuma ser
122
+ * atalho do navegador ou do sistema; quem quiser um deles declara `ctrl`.
123
+ *
124
+ * ```tsx
125
+ * // Na listagem: duas batidas abrem o cadastro.
126
+ * useShortcut(CREATE_SHORTCUT_KEYS, openCreate, { double: true });
127
+ *
128
+ * // Dentro do modal: a tecla é dele, então `allowInDialog`.
129
+ * useShortcut(EXPAND_SHORTCUT_KEY, alternarTamanho, {
130
+ * alt: true,
131
+ * allowInDialog: true,
132
+ * allowWhileTyping: true,
133
+ * });
134
+ * ```
135
+ */
136
+ export function useShortcut(
137
+ keys: string | readonly string[],
138
+ handler: () => void,
139
+ options: ShortcutOptions = {},
140
+ ): void {
141
+ const {
142
+ double = false,
143
+ enabled = true,
144
+ alt = false,
145
+ ctrl = false,
146
+ shift,
147
+ allowInDialog = false,
148
+ allowWhileTyping = false,
149
+ } = options;
150
+
151
+ // Serializado para o efeito não se reinscrever a cada render quando a tela
152
+ // passa um array literal — `["1", "\\"]` é um objeto novo toda vez.
153
+ const keyList = typeof keys === "string" ? [keys] : keys;
154
+ const keySignature = keyList.join("\u0000");
155
+
156
+ // O handler entra por ref para o efeito não se reinscrever a cada render —
157
+ // a tela costuma passar uma arrow nova toda vez. A escrita vai num efeito
158
+ // próprio: mexer em ref durante o render é o que o React proíbe.
159
+ const handlerRef = useRef(handler);
160
+
161
+ useEffect(() => {
162
+ handlerRef.current = handler;
163
+ }, [handler]);
164
+
165
+ /**
166
+ * Qual tecla bateu por último, e quando.
167
+ *
168
+ * A tecla entra junto do instante porque o duplo é da **mesma** tecla: sem
169
+ * isso, digitar `1` e logo `\` valeria como duas batidas e abriria o modal
170
+ * sem ninguém ter pedido.
171
+ */
172
+ const lastPressRef = useRef<{ key: string; at: number }>({ key: "", at: 0 });
173
+
174
+ useEffect(() => {
175
+ if (!enabled) {
176
+ return;
177
+ }
178
+
179
+ const accepted = keySignature.split("\u0000");
180
+
181
+ const onKeyDown = (event: KeyboardEvent): void => {
182
+ if (!accepted.includes(event.key)) {
183
+ return;
184
+ }
185
+ if (event.altKey !== alt) {
186
+ return;
187
+ }
188
+ if (event.ctrlKey || event.metaKey ? !ctrl : ctrl) {
189
+ return;
190
+ }
191
+ if (shift !== undefined && event.shiftKey !== shift) {
192
+ return;
193
+ }
194
+ const typing = isTyping(event.target);
195
+
196
+ if (typing && !allowWhileTyping) {
197
+ return;
198
+ }
199
+ if (!allowInDialog && hasOpenDialog()) {
200
+ return;
201
+ }
202
+
203
+ if (double) {
204
+ const now = Date.now();
205
+ const previous = lastPressRef.current;
206
+ lastPressRef.current = { key: event.key, at: now };
207
+
208
+ if (
209
+ previous.key !== event.key ||
210
+ now - previous.at > DOUBLE_PRESS_WINDOW_MS
211
+ ) {
212
+ return;
213
+ }
214
+ // Zera para a terceira batida não valer como um segundo disparo.
215
+ lastPressRef.current = { key: "", at: 0 };
216
+ }
217
+
218
+ // Só depois de decidir que dispara: impedir o padrão antes disso
219
+ // engoliria a tecla de quem só queria digitá-la.
220
+ //
221
+ // Com modificador não há esse risco — `Alt + Enter` não escreve nada —, e
222
+ // aí engolir é necessário: sem isso o `Enter` ainda enviaria o formulário
223
+ // junto com o ampliar. Sem modificador e dentro de um campo, a tecla é
224
+ // das duas coisas ao mesmo tempo, e não se impede nada.
225
+ if (!typing || alt || ctrl) {
226
+ event.preventDefault();
227
+ }
228
+ handlerRef.current();
229
+ };
230
+
231
+ // **Fase de captura, e não de bolha.** Atalho global precisa ver a tecla
232
+ // antes de quem está no caminho: um componente entre o campo e a `window`
233
+ // que chame `stopPropagation()` no `keydown` — coisa comum em combobox,
234
+ // editor e tabela — engoliria o atalho, e o sintoma seria "funciona numa
235
+ // tela e não funciona na outra", que é o pior tipo de bug para achar.
236
+ window.addEventListener("keydown", onKeyDown, true);
237
+ return () => window.removeEventListener("keydown", onKeyDown, true);
238
+ }, [
239
+ keySignature,
240
+ double,
241
+ enabled,
242
+ alt,
243
+ ctrl,
244
+ shift,
245
+ allowInDialog,
246
+ allowWhileTyping,
247
+ ]);
248
+ }
249
+
250
+ const isTyping = (target: EventTarget | null): boolean => {
251
+ if (!(target instanceof HTMLElement)) {
252
+ return false;
253
+ }
254
+
255
+ return TYPING_TAGS.has(target.tagName) || target.isContentEditable;
256
+ };
257
+
258
+ /**
259
+ * Há um modal aberto?
260
+ *
261
+ * Pela marca que o Radix deixa no DOM, e não por um contexto próprio: o modal
262
+ * pode estar em qualquer lugar da árvore (ele vive num portal), e um contexto
263
+ * obrigaria toda tela a envolver o conteúdo num provider só para o atalho
264
+ * saber disso.
265
+ */
266
+ const hasOpenDialog = (): boolean =>
267
+ document.querySelector('[role="dialog"][data-state="open"]') !== null;
@@ -12,6 +12,8 @@ export const en: Messages = {
12
12
  logs: "Logs",
13
13
  audit: "Audit",
14
14
  queues: "Queues",
15
+ shortcuts: "Shortcuts",
16
+ settings: "Settings",
15
17
  },
16
18
  logs: {
17
19
  outcomeSuccess: "Success",
@@ -109,6 +111,41 @@ export const en: Messages = {
109
111
  stateCompleted: "Completed",
110
112
  stateFailed: "Failed",
111
113
  stateDelayed: "Delayed",
114
+ queue: "Queue",
115
+ queueJobs: "General",
116
+ queueExports: "Reports",
117
+ payload: "Payload",
118
+ },
119
+ shortcuts: {
120
+ title: "Keyboard shortcuts",
121
+ subtitle:
122
+ "What you can do without leaving the keyboard. Shortcuts work on any screen that has the matching action.",
123
+ or: "or",
124
+ twice: "twice",
125
+ create: {
126
+ name: "Open the create form",
127
+ where: "On any listing with a create button.",
128
+ caveat: "Does not fire inside a text field or while a dialog is open",
129
+ },
130
+ expand: {
131
+ name: "Expand the dialog",
132
+ where:
133
+ "With a dialog open, toggles between normal and expanded size. Works anywhere in the dialog, like Esc.",
134
+ caveat: "Works in any field, and does not submit the form",
135
+ note: "On a Mac, Alt is the Option key (⌥) — most keyboards print both labels on it.",
136
+ },
137
+ submit: {
138
+ name: "Submit the form",
139
+ where: "With a create dialog open, saves without clicking.",
140
+ caveat: "Inside a long text field, Enter adds a line break",
141
+ },
142
+ close: {
143
+ name: "Close the dialog",
144
+ where: "Closes the open dialog, same as the close button.",
145
+ caveat: "A changed form asks for confirmation before leaving",
146
+ },
147
+ footer:
148
+ "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.",
112
149
  },
113
150
  table: {
114
151
  columns: "Columns",
@@ -122,6 +159,8 @@ export const en: Messages = {
122
159
  nextPage: "Next page",
123
160
  empty: "No records",
124
161
  emptyFiltered: "No records match this filter",
162
+ reorder: "Drag to reorder",
163
+ reorderSorted: "Clear the column sorting to drag rows",
125
164
  },
126
165
  filters: {
127
166
  button: "Filters",
@@ -209,6 +248,7 @@ export const en: Messages = {
209
248
  clear: "Clear",
210
249
  no: "No",
211
250
  actions: "Actions",
251
+ shortcutHint: "Shortcut: {keys}",
212
252
  loading: "Loading...",
213
253
  description: "Description",
214
254
  language: "Language",
@@ -10,6 +10,8 @@ export const pt = {
10
10
  logs: "Logs",
11
11
  audit: "Auditoria",
12
12
  queues: "Filas",
13
+ shortcuts: "Atalhos",
14
+ settings: "Configurações",
13
15
  },
14
16
  logs: {
15
17
  outcomeSuccess: "Sucesso",
@@ -111,6 +113,41 @@ export const pt = {
111
113
  stateCompleted: "Concluídos",
112
114
  stateFailed: "Falhos",
113
115
  stateDelayed: "Agendados",
116
+ queue: "Fila",
117
+ queueJobs: "Geral",
118
+ queueExports: "Relatórios",
119
+ payload: "Conteúdo",
120
+ },
121
+ shortcuts: {
122
+ title: "Atalhos de teclado",
123
+ subtitle:
124
+ "O que dá para fazer sem tirar a mão do teclado. Os atalhos valem em qualquer tela que tenha a ação correspondente.",
125
+ or: "ou",
126
+ twice: "duas vezes",
127
+ create: {
128
+ name: "Abrir cadastro",
129
+ where: "Em qualquer listagem que tenha o botão de cadastrar.",
130
+ caveat: "Não dispara dentro de um campo de texto nem com um modal aberto",
131
+ },
132
+ expand: {
133
+ name: "Ampliar o modal",
134
+ where:
135
+ "Com um modal aberto, alterna entre o tamanho normal e o ampliado. Vale em qualquer lugar do modal, como o Esc.",
136
+ caveat: "Funciona em qualquer campo, e não envia o formulário junto",
137
+ note: "No Mac, o Alt é a tecla Option (⌥) — em boa parte dos teclados ela vem escrita das duas formas.",
138
+ },
139
+ submit: {
140
+ name: "Enviar o formulário",
141
+ where: "Com um modal de cadastro aberto, salva sem precisar clicar.",
142
+ caveat: "Dentro de um campo de texto longo, o Enter quebra a linha",
143
+ },
144
+ close: {
145
+ name: "Fechar o modal",
146
+ where: "Fecha o modal aberto, como o botão de fechar.",
147
+ caveat: "Um formulário alterado pede confirmação antes de sair",
148
+ },
149
+ footer:
150
+ "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.",
114
151
  },
115
152
  table: {
116
153
  columns: "Colunas",
@@ -124,6 +161,9 @@ export const pt = {
124
161
  nextPage: "Próxima página",
125
162
  empty: "Nenhum registro",
126
163
  emptyFiltered: "Nenhum registro para este filtro",
164
+ reorder: "Arraste para reordenar",
165
+ reorderSorted:
166
+ "Limpe a ordenação por coluna para arrastar as linhas",
127
167
  },
128
168
  filters: {
129
169
  button: "Filtros",
@@ -211,6 +251,7 @@ export const pt = {
211
251
  clear: "Limpar",
212
252
  no: "Não",
213
253
  actions: "Ações",
254
+ shortcutHint: "Atalho: {keys}",
214
255
  loading: "Carregando...",
215
256
  description: "Descrição",
216
257
  language: "Idioma",
package/src/index.ts CHANGED
@@ -33,6 +33,7 @@ export {
33
33
  export { RbacScreen } from "#core/features/rbac/rbac-screen";
34
34
  export { ForgotPasswordScreen } from "#core/features/recovery/forgot-password-screen";
35
35
  export { ResetPasswordScreen } from "#core/features/recovery/reset-password-screen";
36
+ export { ShortcutsScreen } from "#core/features/shortcuts/shortcuts-screen";
36
37
  export { UsersScreen } from "#core/features/users/users-screen";
37
38
 
38
39
  // ---------------------------------------------------------------------------
@@ -70,6 +71,7 @@ export {
70
71
  // ---------------------------------------------------------------------------
71
72
  // Hooks de listagem, requisição e filtro
72
73
  // ---------------------------------------------------------------------------
74
+ export type { CreateButtonProps } from "#core/components/ui/create-button";
73
75
  export type { UseColumnVisibilityResult } from "#core/hooks/use-column-visibility";
74
76
  export { useColumnVisibility } from "#core/hooks/use-column-visibility";
75
77
  export type {
@@ -97,6 +99,14 @@ export { useLatestRequest } from "#core/hooks/use-latest-request";
97
99
  export type { UseListQueryResult } from "#core/hooks/use-list-query";
98
100
  export { useListQuery } from "#core/hooks/use-list-query";
99
101
  export type { RunOptions, UseRequestResult } from "#core/hooks/use-request";
102
+ export type { ShortcutOptions } from "#core/hooks/use-shortcut";
103
+ export {
104
+ CREATE_SHORTCUT_KEYS,
105
+ CREATE_SHORTCUT_LABEL,
106
+ EXPAND_SHORTCUT_KEY,
107
+ EXPAND_SHORTCUT_LABEL,
108
+ useShortcut,
109
+ } from "#core/hooks/use-shortcut";
100
110
  // RequestOperation é enum (valor, não só tipo): sem ele o projeto não consegue
101
111
  // pedir o toast padrão de criado/alterado/removido que o core já traduz.
102
112
  export { RequestOperation, useRequest } from "#core/hooks/use-request";