rl-core-front 0.16.4 → 0.16.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rl-core-front",
3
- "version": "0.16.4",
3
+ "version": "0.16.6",
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",
@@ -2,6 +2,7 @@
2
2
 
3
3
  import { Slot } from "@radix-ui/react-slot";
4
4
  import { cva, type VariantProps } from "class-variance-authority";
5
+ import { Loader2 } from "lucide-react";
5
6
  import * as React from "react";
6
7
 
7
8
  import { cn } from "#core/lib/utils";
@@ -43,22 +44,54 @@ export interface ButtonProps
43
44
  React.ButtonHTMLAttributes<HTMLButtonElement>,
44
45
  VariantProps<typeof buttonVariants> {
45
46
  asChild?: boolean;
47
+ /**
48
+ * A ação está em curso: mostra o giro e recusa clique.
49
+ *
50
+ * As duas coisas juntas de propósito. Salvar sem sinal nenhum é o que faz
51
+ * clicar de novo, e clicar de novo é o que duplica o lançamento — desabilitar
52
+ * sem mostrar por quê parece que o botão quebrou. Quem passa `loading` não
53
+ * precisa lembrar de passar `disabled` também.
54
+ *
55
+ * Ignorado com `asChild`: ali o filho é que decide o que desenha.
56
+ */
57
+ loading?: boolean;
46
58
  }
47
59
 
48
60
  const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
49
- ({ className, variant, size, asChild = false, ...props }, ref) => {
61
+ (
62
+ { className, variant, size, asChild = false, loading = false, children, ...props },
63
+ ref,
64
+ ) => {
50
65
  const Comp = asChild ? Slot : "button";
66
+
67
+ // O giro entra ANTES do conteúdo, no lugar onde o ícone do botão já ficava:
68
+ // aparecendo depois, o texto pula para a esquerda quando ele some.
69
+ const conteudo =
70
+ loading && !asChild ? (
71
+ <>
72
+ <Loader2 className="animate-spin" aria-hidden />
73
+ {children}
74
+ </>
75
+ ) : (
76
+ children
77
+ );
78
+
51
79
  return (
52
80
  <Comp
53
81
  className={cn(buttonVariants({ variant, size, className }))}
54
82
  ref={ref}
83
+ {...(asChild ? {} : { disabled: loading || props.disabled })}
84
+ // Quem usa leitor de tela não vê o giro: é o `aria-busy` que conta.
85
+ {...(loading && !asChild ? { "aria-busy": true } : {})}
55
86
  // `<button>` sem `type` nasce `submit`: dentro de um form, um botão que
56
87
  // só abre um modal acabava salvando o registro junto. Quem submete
57
88
  // declara `type="submit"` — o spread abaixo deixa isso sobrescrever.
58
89
  // Com `asChild` o filho pode não ser um `<button>`, então não força.
59
90
  {...(asChild ? {} : { type: "button" as const })}
60
91
  {...props}
61
- />
92
+ >
93
+ {conteudo}
94
+ </Comp>
62
95
  );
63
96
  },
64
97
  );
@@ -38,6 +38,18 @@ export interface DatePickerProps {
38
38
  /** Descreve o campo para quem usa leitor de tela. */
39
39
  label?: string;
40
40
  placeholder?: string;
41
+ /**
42
+ * O primeiro dia escolhível (`"yyyy-MM-dd"`) — os anteriores ficam apagados
43
+ * no calendário e o digitado é recusado.
44
+ *
45
+ * Existe porque avisar depois não serve para data: o vencimento anterior à
46
+ * compra e o pagamento anterior ao lançamento são erros que a pessoa comete
47
+ * sem perceber, e descobrir no botão Salvar é refazer o caminho. Aqui o dia
48
+ * simplesmente não se deixa escolher.
49
+ */
50
+ minDay?: string;
51
+ /** O último dia escolhível, pela mesma razão. */
52
+ maxDay?: string;
41
53
  }
42
54
 
43
55
  /**
@@ -69,6 +81,8 @@ export function DatePicker({
69
81
  disabled,
70
82
  label,
71
83
  placeholder,
84
+ minDay,
85
+ maxDay,
72
86
  }: DatePickerProps): JSX.Element {
73
87
  const { t, locale } = useI18n();
74
88
  const [open, setOpen] = useState(false);
@@ -104,8 +118,24 @@ export function DatePicker({
104
118
  caret.current = null;
105
119
  });
106
120
 
121
+ /**
122
+ * O dia está dentro da faixa permitida?
123
+ *
124
+ * Comparação de texto ISO, e não de `Date`: `2026-09-03 < 2026-09-08` é
125
+ * verdade em ordem alfabética porque o formato é fixo, e assim não há
126
+ * conversão de fuso no meio para transformar o dia 1º no dia 30.
127
+ */
128
+ const dayAllowed = (iso: string): boolean =>
129
+ (!minDay || iso >= minDay) && (!maxDay || iso <= maxDay);
130
+
107
131
  const pickDay = (day: Date): void => {
108
- onChange(toIsoDay(day));
132
+ const iso = toIsoDay(day);
133
+
134
+ if (!dayAllowed(iso)) {
135
+ return;
136
+ }
137
+
138
+ onChange(iso);
109
139
  setTyped(null);
110
140
  setOpen(false);
111
141
  };
@@ -129,7 +159,9 @@ export function DatePicker({
129
159
  (selected ?? today()).getFullYear(),
130
160
  );
131
161
 
132
- if (iso) {
162
+ // Digitar é o outro caminho para o mesmo campo: sem esta guarda, o dia que
163
+ // o calendário recusa entraria pelo teclado.
164
+ if (iso && dayAllowed(iso)) {
133
165
  onChange(iso);
134
166
  setMonth(fromIsoDay(iso) ?? month);
135
167
  }
@@ -180,6 +212,11 @@ export function DatePicker({
180
212
  };
181
213
 
182
214
  const dayClass = (day: Date): string => {
215
+ if (!dayAllowed(toIsoDay(day))) {
216
+ // Continua desenhado, e não escondido: o calendário sem os dias 1 a 7
217
+ // pareceria quebrado, e é o cinza que diz "existe, mas não aqui".
218
+ return "text-muted-foreground/30 line-through";
219
+ }
183
220
  if (selected && isSameDay(day, selected)) {
184
221
  return "bg-primary text-primary-foreground font-semibold";
185
222
  }
@@ -285,9 +322,13 @@ export function DatePicker({
285
322
  <button
286
323
  key={day.toISOString()}
287
324
  type="button"
325
+ disabled={!dayAllowed(toIsoDay(day))}
288
326
  onClick={() => pickDay(day)}
289
327
  className={cn(
290
- "flex h-8 w-8 items-center justify-center rounded-full text-sm transition-colors hover:bg-accent",
328
+ "flex h-8 w-8 items-center justify-center rounded-full text-sm transition-colors",
329
+ dayAllowed(toIsoDay(day))
330
+ ? "hover:bg-accent"
331
+ : "cursor-not-allowed hover:bg-transparent",
291
332
  dayClass(day),
292
333
  )}
293
334
  >
@@ -0,0 +1,79 @@
1
+ "use client";
2
+
3
+ import type { FocusEvent, JSX, MouseEvent, ReactNode } from "react";
4
+ import { useState } from "react";
5
+ import { createPortal } from "react-dom";
6
+
7
+ export interface HoverTipProps {
8
+ /** O que o tooltip diz. Vazio não desenha nada. */
9
+ label: string;
10
+ children: ReactNode;
11
+ }
12
+
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
+ /**
23
+ * O rótulo que aparece **na hora** ao passar o mouse.
24
+ *
25
+ * Existe pelo mesmo motivo do `HoverLabel` dos gráficos: o `title` do HTML só
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.
30
+ *
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.
38
+ *
39
+ * O estado mora aqui dentro e guarda só 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.
42
+ */
43
+ export const HoverTip = ({ label, children }: HoverTipProps): JSX.Element => {
44
+ const [position, setPosition] = useState<TipPosition | null>(null);
45
+
46
+ const show = (event: MouseEvent<HTMLElement> | FocusEvent<HTMLElement>): void => {
47
+ const box = event.currentTarget.getBoundingClientRect();
48
+
49
+ setPosition({ x: box.left + box.width / 2, y: box.bottom + GAP });
50
+ };
51
+
52
+ const hide = (): void => setPosition(null);
53
+
54
+ 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>
78
+ );
79
+ };
@@ -20,6 +20,7 @@ export * from "./filter-field";
20
20
  export * from "./filter-group";
21
21
  export * from "./filter-rule";
22
22
  export * from "./filter-sheet";
23
+ export * from "./hover-tip";
23
24
  export * from "./image-upload";
24
25
  export * from "./input";
25
26
  export * from "./job-progress";
@@ -37,6 +37,15 @@ export interface MoneyInputProps {
37
37
  id?: string;
38
38
  placeholder?: string;
39
39
  disabled?: boolean;
40
+ /**
41
+ * Foca o campo ao montar.
42
+ *
43
+ * Existe porque num modal cujo primeiro campo é o valor — a meta do mês, o
44
+ * ajuste de saldo — o `DialogContent` entrega o foco ao botão de fechar do
45
+ * cabeçalho, e quem abriu para digitar um número precisa de um Tab antes de
46
+ * começar.
47
+ */
48
+ autoFocus?: boolean;
40
49
  }
41
50
 
42
51
  /**
@@ -113,6 +122,7 @@ export const MoneyInput = ({
113
122
  id,
114
123
  placeholder,
115
124
  disabled = false,
125
+ autoFocus = false,
116
126
  }: MoneyInputProps): JSX.Element => {
117
127
  const { t } = useI18n();
118
128
  const [open, setOpen] = useState(false);
@@ -218,6 +228,7 @@ export const MoneyInput = ({
218
228
  onBlur={onBlur}
219
229
  placeholder={placeholder}
220
230
  disabled={disabled}
231
+ autoFocus={autoFocus}
221
232
  prefix="R$"
222
233
  inputMode="decimal"
223
234
  className="pr-11"
@@ -254,20 +265,20 @@ export const MoneyInput = ({
254
265
  tabIndex={-1}
255
266
  className="w-72"
256
267
  >
257
- <div className="flex min-h-17 flex-col justify-between gap-1 rounded-md border border-border bg-muted/50 px-3 py-2 text-right">
268
+ <div className="flex min-h-14 flex-col justify-between gap-1 rounded-md border border-border bg-muted/50 px-3 py-1.5 text-right">
258
269
  <span className="min-h-5 break-all font-mono text-xs text-muted-foreground">
259
270
  {toDisplayExpression(expression)}
260
271
  </span>
261
272
  {outcome.message ? (
262
273
  <span className="break-words text-xs text-destructive">{outcome.message}</span>
263
274
  ) : (
264
- <span className="break-all font-mono text-lg font-medium tabular-nums">
275
+ <span className="break-all font-mono text-base font-medium tabular-nums">
265
276
  {formatMoneyBR(outcome.value ?? 0)}
266
277
  </span>
267
278
  )}
268
279
  </div>
269
280
 
270
- <div className="mt-2 grid grid-cols-4 gap-1.5">
281
+ <div className="mt-2 grid grid-cols-4 gap-1">
271
282
  {KEYS.map((key, index) => (
272
283
  <Button
273
284
  key={key.label ?? key.action ?? index}
@@ -276,7 +287,7 @@ export const MoneyInput = ({
276
287
  aria-label={key.labelKey ? t(key.labelKey) : undefined}
277
288
  onClick={() => press(key)}
278
289
  className={cn(
279
- "h-9 px-0 font-mono text-sm",
290
+ "h-8 px-0 font-mono text-sm",
280
291
  key.role === "operator" && "text-primary",
281
292
  key.role === "aux" && "text-muted-foreground",
282
293
  )}
@@ -286,28 +297,31 @@ export const MoneyInput = ({
286
297
  ))}
287
298
  </div>
288
299
 
289
- <div className="mt-2 flex gap-1.5">
300
+ {/*
301
+ A dica saiu daqui e virou o `title` do botão: num notebook de 768px o
302
+ painel inteiro não cabe na altura disponível, e o `PopoverContent`
303
+ passa a rolar — o teclado de uma calculadora com barra de rolagem é
304
+ pior que uma dica a menos.
305
+ */}
306
+ <div className="mt-2 flex gap-1">
290
307
  <Button
291
308
  type="button"
292
309
  variant="outline"
293
- className="h-9 flex-1"
310
+ className="h-8 flex-1"
294
311
  onClick={() => setOpen(false)}
295
312
  >
296
313
  {t("calculator.cancel")}
297
314
  </Button>
298
315
  <Button
299
316
  type="button"
300
- className="h-9 flex-1"
317
+ className="h-8 flex-1"
318
+ title={t("calculator.hint")}
301
319
  disabled={outcome.value === null}
302
320
  onClick={apply}
303
321
  >
304
322
  {t("calculator.apply")}
305
323
  </Button>
306
324
  </div>
307
-
308
- <p className="mt-2 text-center text-[10px] text-muted-foreground">
309
- {t("calculator.hint")}
310
- </p>
311
325
  </PopoverContent>
312
326
  </Popover>
313
327
  );
@@ -17,6 +17,7 @@ import {
17
17
  DialogHeader,
18
18
  DialogTitle,
19
19
  Field,
20
+ HoverTip,
20
21
  Input,
21
22
  RowActions,
22
23
  } from "#core/components/ui";
@@ -157,23 +158,29 @@ export function CatalogPanel({
157
158
  cell: ({ row }) => (
158
159
  <RowActions>
159
160
  {canUpdate && (
160
- <Button
161
- variant="ghost"
162
- size="iconSm"
163
- onClick={() => openEdit(row.original)}
164
- >
165
- <Pencil className="h-4 w-4" />
166
- </Button>
161
+ <HoverTip label={t("common.edit")}>
162
+ <Button
163
+ aria-label={t("common.edit")}
164
+ variant="ghost"
165
+ size="iconSm"
166
+ onClick={() => openEdit(row.original)}
167
+ >
168
+ <Pencil className="h-4 w-4" />
169
+ </Button>
170
+ </HoverTip>
167
171
  )}
168
172
  {canDelete && (
169
- <Button
170
- variant="ghost"
171
- size="iconSm"
172
- className="text-destructive"
173
- onClick={() => removeItem(row.original)}
174
- >
175
- <Trash2 className="h-4 w-4" />
176
- </Button>
173
+ <HoverTip label={t("common.delete")}>
174
+ <Button
175
+ aria-label={t("common.delete")}
176
+ variant="ghost"
177
+ size="iconSm"
178
+ className="text-destructive"
179
+ onClick={() => removeItem(row.original)}
180
+ >
181
+ <Trash2 className="h-4 w-4" />
182
+ </Button>
183
+ </HoverTip>
177
184
  )}
178
185
  </RowActions>
179
186
  ),
@@ -18,6 +18,7 @@ import {
18
18
  DialogHeader,
19
19
  DialogTitle,
20
20
  Field,
21
+ HoverTip,
21
22
  Input,
22
23
  Label,
23
24
  RowActions,
@@ -188,23 +189,29 @@ export function GroupsPanel(): JSX.Element {
188
189
  cell: ({ row }) => (
189
190
  <RowActions>
190
191
  {hasPermission("groups:update:any") && (
191
- <Button
192
- variant="ghost"
193
- size="iconSm"
194
- onClick={() => openEdit(row.original)}
195
- >
196
- <Pencil className="h-4 w-4" />
197
- </Button>
192
+ <HoverTip label={t("common.edit")}>
193
+ <Button
194
+ aria-label={t("common.edit")}
195
+ variant="ghost"
196
+ size="iconSm"
197
+ onClick={() => openEdit(row.original)}
198
+ >
199
+ <Pencil className="h-4 w-4" />
200
+ </Button>
201
+ </HoverTip>
198
202
  )}
199
203
  {hasPermission("groups:delete:any") && (
200
- <Button
201
- variant="ghost"
202
- size="iconSm"
203
- className="text-destructive"
204
- onClick={() => remove(row.original)}
205
- >
206
- <Trash2 className="h-4 w-4" />
207
- </Button>
204
+ <HoverTip label={t("common.delete")}>
205
+ <Button
206
+ aria-label={t("common.delete")}
207
+ variant="ghost"
208
+ size="iconSm"
209
+ className="text-destructive"
210
+ onClick={() => remove(row.original)}
211
+ >
212
+ <Trash2 className="h-4 w-4" />
213
+ </Button>
214
+ </HoverTip>
208
215
  )}
209
216
  </RowActions>
210
217
  ),
@@ -16,6 +16,7 @@ import {
16
16
  DialogHeader,
17
17
  DialogTitle,
18
18
  Field,
19
+ HoverTip,
19
20
  Input,
20
21
  RowActions,
21
22
  } from "#core/components/ui";
@@ -118,23 +119,29 @@ export function PermissionsPanel(): JSX.Element {
118
119
  cell: ({ row }) => (
119
120
  <RowActions>
120
121
  {hasPermission("permissions:update:any") && (
121
- <Button
122
- variant="ghost"
123
- size="iconSm"
124
- onClick={() => openEdit(row.original)}
125
- >
126
- <Pencil className="h-4 w-4" />
127
- </Button>
122
+ <HoverTip label={t("common.edit")}>
123
+ <Button
124
+ aria-label={t("common.edit")}
125
+ variant="ghost"
126
+ size="iconSm"
127
+ onClick={() => openEdit(row.original)}
128
+ >
129
+ <Pencil className="h-4 w-4" />
130
+ </Button>
131
+ </HoverTip>
128
132
  )}
129
133
  {hasPermission("permissions:delete:any") && (
130
- <Button
131
- variant="ghost"
132
- size="iconSm"
133
- className="text-destructive"
134
- onClick={() => remove(row.original)}
135
- >
136
- <Trash2 className="h-4 w-4" />
137
- </Button>
134
+ <HoverTip label={t("common.delete")}>
135
+ <Button
136
+ aria-label={t("common.delete")}
137
+ variant="ghost"
138
+ size="iconSm"
139
+ className="text-destructive"
140
+ onClick={() => remove(row.original)}
141
+ >
142
+ <Trash2 className="h-4 w-4" />
143
+ </Button>
144
+ </HoverTip>
138
145
  )}
139
146
  </RowActions>
140
147
  ),
@@ -19,6 +19,7 @@ import {
19
19
  DialogHeader,
20
20
  DialogTitle,
21
21
  Field,
22
+ HoverTip,
22
23
  Input,
23
24
  Label,
24
25
  } from "#core/components/ui";
@@ -151,23 +152,29 @@ export function RolesPanel(): JSX.Element {
151
152
  {canManage && (
152
153
  <div className="flex">
153
154
  {hasPermission("roles:update:any") && (
154
- <Button
155
- variant="ghost"
156
- size="icon"
157
- onClick={() => openEdit(role)}
158
- >
159
- <Pencil className="h-4 w-4" />
160
- </Button>
155
+ <HoverTip label={t("common.edit")}>
156
+ <Button
157
+ aria-label={t("common.edit")}
158
+ variant="ghost"
159
+ size="icon"
160
+ onClick={() => openEdit(role)}
161
+ >
162
+ <Pencil className="h-4 w-4" />
163
+ </Button>
164
+ </HoverTip>
161
165
  )}
162
166
  {hasPermission("roles:delete:any") && (
163
- <Button
164
- variant="ghost"
165
- size="icon"
166
- className="text-destructive"
167
- onClick={() => remove(role)}
168
- >
169
- <Trash2 className="h-4 w-4" />
170
- </Button>
167
+ <HoverTip label={t("common.delete")}>
168
+ <Button
169
+ aria-label={t("common.delete")}
170
+ variant="ghost"
171
+ size="icon"
172
+ className="text-destructive"
173
+ onClick={() => remove(role)}
174
+ >
175
+ <Trash2 className="h-4 w-4" />
176
+ </Button>
177
+ </HoverTip>
171
178
  )}
172
179
  </div>
173
180
  )}
@@ -26,6 +26,7 @@ import {
26
26
  CreateButton,
27
27
  DataTable,
28
28
  DataTableFeatures,
29
+ HoverTip,
29
30
  RowActions,
30
31
  } from "#core/components/ui";
31
32
  import { useAuth, useI18n } from "#core/contexts";
@@ -152,74 +153,82 @@ export function UsersScreen(): JSX.Element {
152
153
  return (
153
154
  <RowActions>
154
155
  {canUpdate && (
155
- <Button
156
- variant="ghost"
157
- size="iconSm"
158
- title={t("common.edit")}
159
- onClick={() => openEdit(item)}
160
- >
161
- <Pencil className="h-4 w-4" />
162
- </Button>
156
+ <HoverTip label={t("common.edit")}>
157
+ <Button
158
+ variant="ghost"
159
+ size="iconSm"
160
+ aria-label={t("common.edit")}
161
+ onClick={() => openEdit(item)}
162
+ >
163
+ <Pencil className="h-4 w-4" />
164
+ </Button>
165
+ </HoverTip>
163
166
  )}
164
167
  {canInvite && (
165
- <Button
166
- variant="ghost"
167
- size="iconSm"
168
- title={t("users.sendFirstAccessLink")}
169
- onClick={() => {
170
- void confirm({
171
- title: t("users.sendFirstAccessLink"),
172
- description: item.email,
173
- confirmLabel: t("common.send"),
174
- }).then((ok) => {
175
- if (ok) {
176
- void u.sendFirstAccessLink(item.id);
177
- }
178
- });
179
- }}
180
- >
181
- <MailPlus className="h-4 w-4" />
182
- </Button>
168
+ <HoverTip label={t("users.sendFirstAccessLink")}>
169
+ <Button
170
+ variant="ghost"
171
+ size="iconSm"
172
+ aria-label={t("users.sendFirstAccessLink")}
173
+ onClick={() => {
174
+ void confirm({
175
+ title: t("users.sendFirstAccessLink"),
176
+ description: item.email,
177
+ confirmLabel: t("common.send"),
178
+ }).then((ok) => {
179
+ if (ok) {
180
+ void u.sendFirstAccessLink(item.id);
181
+ }
182
+ });
183
+ }}
184
+ >
185
+ <MailPlus className="h-4 w-4" />
186
+ </Button>
187
+ </HoverTip>
183
188
  )}
184
189
  {canReset && (
185
- <Button
186
- variant="ghost"
187
- size="iconSm"
188
- title={t("users.sendPasswordResetLink")}
189
- onClick={() => {
190
- void confirm({
191
- title: t("users.sendPasswordResetLink"),
192
- description: item.email,
193
- confirmLabel: t("common.send"),
194
- }).then((ok) => {
195
- if (ok) {
196
- void u.sendPasswordResetLink(item.id);
197
- }
198
- });
199
- }}
200
- >
201
- <KeyRound className="h-4 w-4" />
202
- </Button>
190
+ <HoverTip label={t("users.sendPasswordResetLink")}>
191
+ <Button
192
+ variant="ghost"
193
+ size="iconSm"
194
+ aria-label={t("users.sendPasswordResetLink")}
195
+ onClick={() => {
196
+ void confirm({
197
+ title: t("users.sendPasswordResetLink"),
198
+ description: item.email,
199
+ confirmLabel: t("common.send"),
200
+ }).then((ok) => {
201
+ if (ok) {
202
+ void u.sendPasswordResetLink(item.id);
203
+ }
204
+ });
205
+ }}
206
+ >
207
+ <KeyRound className="h-4 w-4" />
208
+ </Button>
209
+ </HoverTip>
203
210
  )}
204
211
  {canReset2fa && item.twoFactorEnabled && (
205
- <Button
206
- variant="ghost"
207
- size="iconSm"
208
- title={t("users.resetTwoFactor")}
209
- onClick={() => {
210
- void confirm({
211
- title: t("users.resetTwoFactor"),
212
- description: item.name,
213
- destructive: true,
214
- }).then((ok) => {
215
- if (ok) {
216
- void u.resetTwoFactor(item.id);
217
- }
218
- });
219
- }}
220
- >
221
- <ShieldOff className="h-4 w-4" />
222
- </Button>
212
+ <HoverTip label={t("users.resetTwoFactor")}>
213
+ <Button
214
+ variant="ghost"
215
+ size="iconSm"
216
+ aria-label={t("users.resetTwoFactor")}
217
+ onClick={() => {
218
+ void confirm({
219
+ title: t("users.resetTwoFactor"),
220
+ description: item.name,
221
+ destructive: true,
222
+ }).then((ok) => {
223
+ if (ok) {
224
+ void u.resetTwoFactor(item.id);
225
+ }
226
+ });
227
+ }}
228
+ >
229
+ <ShieldOff className="h-4 w-4" />
230
+ </Button>
231
+ </HoverTip>
223
232
  )}
224
233
  {canDeactivate && (
225
234
  <Button
@@ -238,26 +247,28 @@ export function UsersScreen(): JSX.Element {
238
247
  </Button>
239
248
  )}
240
249
  {canDelete && (
241
- <Button
242
- variant="ghost"
243
- size="iconSm"
244
- className="text-destructive"
245
- title={t("common.delete")}
246
- onClick={() => {
247
- void confirm({
248
- title: t("common.delete"),
249
- description: item.name,
250
- confirmLabel: t("common.delete"),
251
- destructive: true,
252
- }).then((ok) => {
253
- if (ok) {
254
- void u.remove(item.id);
255
- }
256
- });
257
- }}
258
- >
259
- <Trash2 className="h-4 w-4" />
260
- </Button>
250
+ <HoverTip label={t("common.delete")}>
251
+ <Button
252
+ variant="ghost"
253
+ size="iconSm"
254
+ className="text-destructive"
255
+ aria-label={t("common.delete")}
256
+ onClick={() => {
257
+ void confirm({
258
+ title: t("common.delete"),
259
+ description: item.name,
260
+ confirmLabel: t("common.delete"),
261
+ destructive: true,
262
+ }).then((ok) => {
263
+ if (ok) {
264
+ void u.remove(item.id);
265
+ }
266
+ });
267
+ }}
268
+ >
269
+ <Trash2 className="h-4 w-4" />
270
+ </Button>
271
+ </HoverTip>
261
272
  )}
262
273
  </RowActions>
263
274
  );