rl-core-front 0.19.5 → 0.19.7
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/components/ui/column-visibility-modal.tsx +1 -1
- package/src/components/ui/combobox.tsx +1 -1
- package/src/components/ui/data-table.tsx +140 -3
- package/src/components/ui/dialog.tsx +1 -1
- package/src/components/ui/field.tsx +20 -2
- package/src/components/ui/filter-field.tsx +1 -1
- package/src/components/ui/label.tsx +23 -3
- package/src/components/ui/popover.tsx +1 -1
- package/src/components/ui/sheet.tsx +1 -1
- package/src/features/login/components/login-form.tsx +2 -2
- package/src/features/login/components/two-factor-setup.tsx +1 -1
- package/src/features/login/components/two-factor-verify.tsx +1 -1
- package/src/features/profile/force-password-change.tsx +3 -0
- package/src/features/profile/profile-screen.tsx +5 -2
- package/src/features/rbac/panels/catalog-panel.tsx +1 -1
- package/src/features/rbac/panels/groups-panel.tsx +1 -1
- package/src/features/rbac/panels/permissions-panel.tsx +1 -1
- package/src/features/rbac/panels/roles-panel.tsx +1 -1
- package/src/features/recovery/forgot-password-screen.tsx +1 -1
- package/src/features/recovery/reset-password-screen.tsx +2 -0
- package/src/features/users/components/user-form-dialog.tsx +3 -2
- package/src/hooks/use-tab-state.ts +8 -46
- package/src/hooks/use-url-param.ts +80 -0
- package/src/index.ts +1 -0
- package/src/styles/core.css +48 -0
package/package.json
CHANGED
|
@@ -81,7 +81,7 @@ export function ColumnVisibilityModal({
|
|
|
81
81
|
{t("table.columnsHint")}
|
|
82
82
|
</p>
|
|
83
83
|
|
|
84
|
-
<div className="flex max-h-[55vh] flex-col divide-y divide-border overflow-y-auto">
|
|
84
|
+
<div className="flex max-h-[55vh] flex-col divide-y divide-border overflow-y-auto overscroll-contain">
|
|
85
85
|
<div className="flex items-center justify-between py-3">
|
|
86
86
|
<span className="text-sm font-semibold">{t("table.columns")}</span>
|
|
87
87
|
<Switch checked={areAllVisible} onCheckedChange={toggleAll} />
|
|
@@ -422,7 +422,7 @@ export function Combobox({
|
|
|
422
422
|
* campo sem cobri-lo.
|
|
423
423
|
*/
|
|
424
424
|
className={cn(
|
|
425
|
-
"max-h-[min(18rem,var(--radix-dropdown-menu-content-available-height))] w-(--radix-dropdown-menu-trigger-width) overflow-y-auto p-1",
|
|
425
|
+
"max-h-[min(18rem,var(--radix-dropdown-menu-content-available-height))] w-(--radix-dropdown-menu-trigger-width) overflow-y-auto overscroll-contain p-1",
|
|
426
426
|
!multiple && "data-[side=bottom]:-mt-(--radix-dropdown-menu-trigger-height)",
|
|
427
427
|
)}
|
|
428
428
|
align="start"
|
|
@@ -280,6 +280,25 @@ interface DataTableProps<T extends RowData> {
|
|
|
280
280
|
* (a receber / recebidas) declara esta lista.
|
|
281
281
|
*/
|
|
282
282
|
groupOrder?: string[];
|
|
283
|
+
/**
|
|
284
|
+
* A linha que um link aponta — pelo id de `getRowId`.
|
|
285
|
+
*
|
|
286
|
+
* A tabela abre a seção em que ela está, rola até ela e a destaca por alguns
|
|
287
|
+
* segundos: é o que faz o aviso "luz venceu" cair na linha da luz, e não no
|
|
288
|
+
* topo de um mês de trinta lançamentos. Acontece quando o id troca e quando
|
|
289
|
+
* a linha chega (o dado costuma vir depois do link); fora disso a linha é
|
|
290
|
+
* uma linha como as outras — o destaque é um sinal, não um estado.
|
|
291
|
+
*
|
|
292
|
+
* Exige `getRowId`. Id que não está em `data` não faz nada.
|
|
293
|
+
*/
|
|
294
|
+
highlightRowId?: string | null;
|
|
295
|
+
/**
|
|
296
|
+
* A seção que um link aponta — pela chave de `groupBy`.
|
|
297
|
+
*
|
|
298
|
+
* O mesmo gesto, na faixa: para o aviso que fala da fatura inteira, e não de
|
|
299
|
+
* uma parcela dela.
|
|
300
|
+
*/
|
|
301
|
+
highlightGroupKey?: string | null;
|
|
283
302
|
}
|
|
284
303
|
|
|
285
304
|
/** Uma seção da listagem, como o cabeçalho de grupo a recebe. */
|
|
@@ -458,6 +477,20 @@ function shrinkClass(columnDef: { meta?: unknown }): string | undefined {
|
|
|
458
477
|
*/
|
|
459
478
|
const CELL_MAX_WIDTH = "24rem";
|
|
460
479
|
|
|
480
|
+
/**
|
|
481
|
+
* Quanto tempo a linha apontada fica destacada.
|
|
482
|
+
*
|
|
483
|
+
* O bastante para o olho chegar depois da rolagem e curto o bastante para não
|
|
484
|
+
* parecer seleção: passado isso, a cor esmaece e a linha volta ao normal.
|
|
485
|
+
*/
|
|
486
|
+
const HIGHLIGHT_MS = 3000;
|
|
487
|
+
|
|
488
|
+
/** O que está destacado agora — a linha, a faixa, ou nada. */
|
|
489
|
+
interface Spotlight {
|
|
490
|
+
rowId: string | null;
|
|
491
|
+
groupKey: string | null;
|
|
492
|
+
}
|
|
493
|
+
|
|
461
494
|
/**
|
|
462
495
|
* Como a célula se comporta quando o conteúdo não cabe: corta com reticências.
|
|
463
496
|
*
|
|
@@ -571,6 +604,8 @@ export function DataTable<T extends RowData>({
|
|
|
571
604
|
groupOrder,
|
|
572
605
|
collapsibleGroups = false,
|
|
573
606
|
defaultCollapsedGroups,
|
|
607
|
+
highlightRowId = null,
|
|
608
|
+
highlightGroupKey = null,
|
|
574
609
|
}: DataTableProps<T>): JSX.Element {
|
|
575
610
|
const { t } = useI18n();
|
|
576
611
|
const [colsOpen, setColsOpen] = useState(false);
|
|
@@ -578,6 +613,93 @@ export function DataTable<T extends RowData>({
|
|
|
578
613
|
const [collapsedGroups, setCollapsedGroups] = useState<string[]>(
|
|
579
614
|
() => defaultCollapsedGroups ?? [],
|
|
580
615
|
);
|
|
616
|
+
/*
|
|
617
|
+
O que está apontado e se ainda está aceso.
|
|
618
|
+
|
|
619
|
+
Dois estados porque a transição de cor fica ligada na linha apontada mesmo
|
|
620
|
+
depois de a cor sair: se ela morasse só enquanto acesa, sumiria junto com
|
|
621
|
+
a cor e o esmaecer viraria um corte. E só nessa linha, e não em todas —
|
|
622
|
+
com a transição na tabela inteira, o hover de cada linha demoraria para
|
|
623
|
+
acender.
|
|
624
|
+
*/
|
|
625
|
+
const [spotlight, setSpotlight] = useState<Spotlight | null>(null);
|
|
626
|
+
const [lit, setLit] = useState(false);
|
|
627
|
+
/** O pedido já atendido — para o mesmo link não acender a linha a cada render. */
|
|
628
|
+
const [seenHighlight, setSeenHighlight] = useState<string | null>(null);
|
|
629
|
+
const bodyRef = useRef<HTMLTableSectionElement>(null);
|
|
630
|
+
|
|
631
|
+
/*
|
|
632
|
+
A linha apontada, se ela está em `data`; e a seção dela, para abrir.
|
|
633
|
+
|
|
634
|
+
Procurada em `data` e não no modelo da tabela: fechada, a seção tira as
|
|
635
|
+
linhas do modelo, e a linha que se quer abrir é justamente a que está
|
|
636
|
+
escondida.
|
|
637
|
+
*/
|
|
638
|
+
const highlightedRow =
|
|
639
|
+
highlightRowId !== null && getRowId
|
|
640
|
+
? data.find((row) => getRowId(row) === highlightRowId)
|
|
641
|
+
: undefined;
|
|
642
|
+
const highlightedGroup =
|
|
643
|
+
highlightGroupKey ??
|
|
644
|
+
(highlightedRow !== undefined && groupBy ? groupBy(highlightedRow) : null);
|
|
645
|
+
const highlightTarget =
|
|
646
|
+
highlightedRow !== undefined ||
|
|
647
|
+
(highlightGroupKey !== null && Boolean(groupBy) &&
|
|
648
|
+
data.some((row) => groupBy?.(row) === highlightGroupKey));
|
|
649
|
+
const highlightKey = highlightTarget
|
|
650
|
+
? `${highlightRowId ?? ""}|${highlightGroupKey ?? ""}`
|
|
651
|
+
: null;
|
|
652
|
+
|
|
653
|
+
/*
|
|
654
|
+
Reage ao link no próprio render, e não num efeito: é o padrão do React
|
|
655
|
+
para "estado que depende da prop anterior", e poupa o quadro em que a
|
|
656
|
+
seção ainda estaria fechada. Quando o alvo some da lista (a tela trocou de
|
|
657
|
+
mês), o pedido é esquecido — e a linha acende de novo se voltar.
|
|
658
|
+
*/
|
|
659
|
+
if (highlightKey !== seenHighlight) {
|
|
660
|
+
setSeenHighlight(highlightKey);
|
|
661
|
+
|
|
662
|
+
if (highlightKey !== null) {
|
|
663
|
+
setSpotlight({ rowId: highlightRowId, groupKey: highlightGroupKey });
|
|
664
|
+
setLit(true);
|
|
665
|
+
|
|
666
|
+
if (highlightedGroup !== null) {
|
|
667
|
+
setCollapsedGroups((atual) =>
|
|
668
|
+
atual.filter((fechada) => fechada !== highlightedGroup),
|
|
669
|
+
);
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
useEffect(() => {
|
|
675
|
+
if (!lit) {
|
|
676
|
+
return;
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
const timer = window.setTimeout(() => setLit(false), HIGHLIGHT_MS);
|
|
680
|
+
|
|
681
|
+
return () => window.clearTimeout(timer);
|
|
682
|
+
// `spotlight` entra para o relógio recomeçar quando o alvo troca com a
|
|
683
|
+
// linha anterior ainda acesa.
|
|
684
|
+
}, [lit, spotlight]);
|
|
685
|
+
|
|
686
|
+
// Rola depois de a seção abrir: a linha só existe no DOM depois do render
|
|
687
|
+
// que a abriu, e um efeito roda justamente depois dele.
|
|
688
|
+
useEffect(() => {
|
|
689
|
+
if (spotlight === null) {
|
|
690
|
+
return;
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
const selector =
|
|
694
|
+
spotlight.rowId !== null
|
|
695
|
+
? `[data-row-id="${CSS.escape(spotlight.rowId)}"]`
|
|
696
|
+
: spotlight.groupKey !== null
|
|
697
|
+
? `[data-group-key="${CSS.escape(spotlight.groupKey)}"]`
|
|
698
|
+
: null;
|
|
699
|
+
const alvo = selector ? bodyRef.current?.querySelector(selector) : null;
|
|
700
|
+
|
|
701
|
+
alvo?.scrollIntoView({ block: "center", behavior: "smooth" });
|
|
702
|
+
}, [spotlight]);
|
|
581
703
|
const [filtersOpen, setFiltersOpen] = useState(false);
|
|
582
704
|
/** A linha na mão e a linha embaixo do dedo, enquanto o arrasto dura. */
|
|
583
705
|
const [draggedRow, setDraggedRow] = useState<string | null>(null);
|
|
@@ -1102,7 +1224,7 @@ export function DataTable<T extends RowData>({
|
|
|
1102
1224
|
</TableRow>
|
|
1103
1225
|
))}
|
|
1104
1226
|
</TableHeader>
|
|
1105
|
-
<TableBody>
|
|
1227
|
+
<TableBody ref={bodyRef}>
|
|
1106
1228
|
{loading && (
|
|
1107
1229
|
<TableRow>
|
|
1108
1230
|
<TableCell colSpan={colSpan} className="h-24 text-center">
|
|
@@ -1124,13 +1246,21 @@ export function DataTable<T extends RowData>({
|
|
|
1124
1246
|
sections.map((section) => (
|
|
1125
1247
|
<Fragment key={section.key}>
|
|
1126
1248
|
{section.header && renderGroupHeader && (
|
|
1127
|
-
<TableRow
|
|
1249
|
+
<TableRow
|
|
1250
|
+
data-group-key={section.key}
|
|
1251
|
+
className="hover:bg-transparent"
|
|
1252
|
+
>
|
|
1128
1253
|
{/* Uma célula atravessando a tabela: é o que faz a
|
|
1129
1254
|
faixa parecer cabeçalho, e não registro sem
|
|
1130
1255
|
dados. */}
|
|
1131
1256
|
<TableCell
|
|
1132
1257
|
colSpan={colSpan}
|
|
1133
|
-
className=
|
|
1258
|
+
className={cn(
|
|
1259
|
+
"bg-muted/40 py-2",
|
|
1260
|
+
spotlight?.groupKey === section.key &&
|
|
1261
|
+
"transition-colors duration-700",
|
|
1262
|
+
lit && spotlight?.groupKey === section.key && "bg-primary/15",
|
|
1263
|
+
)}
|
|
1134
1264
|
>
|
|
1135
1265
|
{collapsibleGroups ? (
|
|
1136
1266
|
<button
|
|
@@ -1174,6 +1304,13 @@ export function DataTable<T extends RowData>({
|
|
|
1174
1304
|
dragging &&
|
|
1175
1305
|
"relative z-20 bg-card shadow-xl ring-1 ring-primary/50",
|
|
1176
1306
|
draggedRow !== null && "select-none",
|
|
1307
|
+
// A linha apontada acende e esmaece: a cor some
|
|
1308
|
+
// sozinha quando o destaque acaba, e a transição
|
|
1309
|
+
// é o que faz isso parecer "olhe aqui", e não
|
|
1310
|
+
// uma seleção que ficou.
|
|
1311
|
+
spotlight?.rowId === row.id &&
|
|
1312
|
+
"transition-colors duration-700",
|
|
1313
|
+
lit && spotlight?.rowId === row.id && "bg-primary/15",
|
|
1177
1314
|
/*
|
|
1178
1315
|
A transição existe só enquanto o arrasto dura, e só nas
|
|
1179
1316
|
linhas paradas: na que segue o dedo ela viraria atraso,
|
|
@@ -515,7 +515,7 @@ const DialogForm = React.forwardRef<
|
|
|
515
515
|
vertical cortava a borda de baixo do último campo — que é o que
|
|
516
516
|
aparece em todo modal com um campo colado no rodapé.
|
|
517
517
|
`-m-1` devolve o recuo para o conteúdo seguir alinhado. */}
|
|
518
|
-
<div className="-m-1 flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto p-1
|
|
518
|
+
<div className="-m-1 flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto overscroll-contain p-1">
|
|
519
519
|
{hasFooter ? items.slice(0, footerAt) : items}
|
|
520
520
|
</div>
|
|
521
521
|
{hasFooter && items.slice(footerAt)}
|
|
@@ -6,13 +6,31 @@ interface FieldProps {
|
|
|
6
6
|
label?: string;
|
|
7
7
|
error?: string;
|
|
8
8
|
htmlFor?: string;
|
|
9
|
+
/**
|
|
10
|
+
* Marca o rótulo com o "*" de obrigatório.
|
|
11
|
+
*
|
|
12
|
+
* Fica no rótulo, e não no controle, porque é o único lugar comum a todos
|
|
13
|
+
* eles: `Input`, `Combobox`, `MoneyInput` e `DatePicker` desenham a caixa de
|
|
14
|
+
* um jeito cada um, e a marca precisa aparecer no mesmo ponto nos quatro.
|
|
15
|
+
*/
|
|
16
|
+
required?: boolean;
|
|
9
17
|
children: React.ReactNode;
|
|
10
18
|
}
|
|
11
19
|
|
|
12
|
-
export function Field({
|
|
20
|
+
export function Field({
|
|
21
|
+
label,
|
|
22
|
+
error,
|
|
23
|
+
htmlFor,
|
|
24
|
+
required,
|
|
25
|
+
children,
|
|
26
|
+
}: FieldProps): JSX.Element {
|
|
13
27
|
return (
|
|
14
28
|
<div className="space-y-1.5">
|
|
15
|
-
{label &&
|
|
29
|
+
{label && (
|
|
30
|
+
<Label htmlFor={htmlFor} required={required}>
|
|
31
|
+
{label}
|
|
32
|
+
</Label>
|
|
33
|
+
)}
|
|
16
34
|
{children}
|
|
17
35
|
{error && <p className="text-xs font-medium text-destructive">{error}</p>}
|
|
18
36
|
</div>
|
|
@@ -140,7 +140,7 @@ export function FilterFieldControl({
|
|
|
140
140
|
<fieldset className="space-y-2">
|
|
141
141
|
<legend className="text-sm font-medium">{label}</legend>
|
|
142
142
|
{field.options?.length ? (
|
|
143
|
-
<div className="max-h-44 space-y-2 overflow-y-auto rounded-md border border-border p-3">
|
|
143
|
+
<div className="max-h-44 space-y-2 overflow-y-auto overscroll-contain rounded-md border border-border p-3">
|
|
144
144
|
{field.options.map((option) => (
|
|
145
145
|
<label
|
|
146
146
|
key={option.value}
|
|
@@ -5,10 +5,23 @@ import * as React from "react";
|
|
|
5
5
|
|
|
6
6
|
import { cn } from "#core/lib/utils";
|
|
7
7
|
|
|
8
|
+
export interface LabelProps
|
|
9
|
+
extends React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> {
|
|
10
|
+
/**
|
|
11
|
+
* Campo obrigatório: acrescenta o "*" vermelho depois do texto.
|
|
12
|
+
*
|
|
13
|
+
* A marca é só visual (`aria-hidden`) — quem usa leitor de tela ouve a
|
|
14
|
+
* obrigatoriedade do próprio controle, então o campo também precisa do
|
|
15
|
+
* `required`/`aria-required`. Um asterisco lido em voz alta no meio do
|
|
16
|
+
* rótulo não diz nada; o atributo no controle diz.
|
|
17
|
+
*/
|
|
18
|
+
required?: boolean;
|
|
19
|
+
}
|
|
20
|
+
|
|
8
21
|
const Label = React.forwardRef<
|
|
9
22
|
React.ElementRef<typeof LabelPrimitive.Root>,
|
|
10
|
-
|
|
11
|
-
>(({ className, ...props }, ref) => (
|
|
23
|
+
LabelProps
|
|
24
|
+
>(({ className, required, children, ...props }, ref) => (
|
|
12
25
|
<LabelPrimitive.Root
|
|
13
26
|
ref={ref}
|
|
14
27
|
className={cn(
|
|
@@ -16,7 +29,14 @@ const Label = React.forwardRef<
|
|
|
16
29
|
className,
|
|
17
30
|
)}
|
|
18
31
|
{...props}
|
|
19
|
-
|
|
32
|
+
>
|
|
33
|
+
{children}
|
|
34
|
+
{required && (
|
|
35
|
+
<span aria-hidden className="ml-0.5 text-destructive">
|
|
36
|
+
*
|
|
37
|
+
</span>
|
|
38
|
+
)}
|
|
39
|
+
</LabelPrimitive.Root>
|
|
20
40
|
));
|
|
21
41
|
Label.displayName = LabelPrimitive.Root.displayName;
|
|
22
42
|
|
|
@@ -45,7 +45,7 @@ const PopoverContent = React.forwardRef<
|
|
|
45
45
|
// com a última linha rente ao fim da tela.
|
|
46
46
|
collisionPadding={8}
|
|
47
47
|
className={cn(
|
|
48
|
-
"z-50 max-h-(--radix-popover-content-available-height) overflow-y-auto rounded-xl border border-border bg-popover p-3 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
|
48
|
+
"z-50 max-h-(--radix-popover-content-available-height) overflow-y-auto overscroll-contain rounded-xl border border-border bg-popover p-3 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
|
49
49
|
className,
|
|
50
50
|
)}
|
|
51
51
|
{...props}
|
|
@@ -66,7 +66,7 @@ const SheetBody = ({
|
|
|
66
66
|
className,
|
|
67
67
|
...props
|
|
68
68
|
}: React.HTMLAttributes<HTMLDivElement>): React.JSX.Element => (
|
|
69
|
-
<div className={cn("flex-1 overflow-y-auto", className)} {...props} />
|
|
69
|
+
<div className={cn("flex-1 overflow-y-auto overscroll-contain", className)} {...props} />
|
|
70
70
|
);
|
|
71
71
|
SheetBody.displayName = "SheetBody";
|
|
72
72
|
|
|
@@ -46,7 +46,7 @@ export function LoginForm({
|
|
|
46
46
|
onSubmit={handleSubmit((d) => onSubmit(d.email, d.password))}
|
|
47
47
|
className="flex flex-col gap-4"
|
|
48
48
|
>
|
|
49
|
-
<Field label={t("auth.email")} error={errors.email?.message}>
|
|
49
|
+
<Field label={t("auth.email")} error={errors.email?.message} required>
|
|
50
50
|
<Input
|
|
51
51
|
type="email"
|
|
52
52
|
autoComplete="email"
|
|
@@ -55,7 +55,7 @@ export function LoginForm({
|
|
|
55
55
|
{...register("email")}
|
|
56
56
|
/>
|
|
57
57
|
</Field>
|
|
58
|
-
<Field label={t("auth.password")} error={errors.password?.message}>
|
|
58
|
+
<Field label={t("auth.password")} error={errors.password?.message} required>
|
|
59
59
|
<PasswordField
|
|
60
60
|
autoComplete="current-password"
|
|
61
61
|
autoFocus={!!defaultEmail}
|
|
@@ -64,7 +64,7 @@ export function TwoFactorSetup({
|
|
|
64
64
|
onSubmit={handleSubmit((d) => onConfirm(d.code))}
|
|
65
65
|
className="flex flex-col gap-4"
|
|
66
66
|
>
|
|
67
|
-
<Field label={t("auth.codeApp")} error={errors.code?.message}>
|
|
67
|
+
<Field label={t("auth.codeApp")} error={errors.code?.message} required>
|
|
68
68
|
<Input
|
|
69
69
|
autoFocus
|
|
70
70
|
inputMode="numeric"
|
|
@@ -35,7 +35,7 @@ export function TwoFactorVerify({ loading, onVerify, onRequestEmail }: Props): J
|
|
|
35
35
|
onSubmit={handleSubmit((d) => onVerify(d.code))}
|
|
36
36
|
className="flex flex-col gap-4"
|
|
37
37
|
>
|
|
38
|
-
<Field label={t("auth.codeAny")} error={errors.code?.message}>
|
|
38
|
+
<Field label={t("auth.codeAny")} error={errors.code?.message} required>
|
|
39
39
|
<Input autoFocus {...register("code")} />
|
|
40
40
|
</Field>
|
|
41
41
|
<Button type="submit" size="lg" disabled={loading}>
|
|
@@ -79,6 +79,7 @@ export function ForcePasswordChange({
|
|
|
79
79
|
<Field
|
|
80
80
|
label={t("profile.currentPassword")}
|
|
81
81
|
error={errors.currentPassword?.message}
|
|
82
|
+
required
|
|
82
83
|
>
|
|
83
84
|
<PasswordField
|
|
84
85
|
error={!!errors.currentPassword}
|
|
@@ -88,6 +89,7 @@ export function ForcePasswordChange({
|
|
|
88
89
|
<Field
|
|
89
90
|
label={t("profile.newPassword")}
|
|
90
91
|
error={errors.newPassword?.message}
|
|
92
|
+
required
|
|
91
93
|
>
|
|
92
94
|
<PasswordField
|
|
93
95
|
error={!!errors.newPassword}
|
|
@@ -98,6 +100,7 @@ export function ForcePasswordChange({
|
|
|
98
100
|
<Field
|
|
99
101
|
label={t("profile.confirmPassword")}
|
|
100
102
|
error={errors.confirm?.message}
|
|
103
|
+
required
|
|
101
104
|
>
|
|
102
105
|
<PasswordField
|
|
103
106
|
error={!!errors.confirm}
|
|
@@ -249,13 +249,13 @@ export function ProfileScreen(): JSX.Element {
|
|
|
249
249
|
</div>
|
|
250
250
|
|
|
251
251
|
<h2 className="text-lg font-semibold">{t("profile.data")}</h2>
|
|
252
|
-
<Field label={t("users.name")} error={formErrors.name}>
|
|
252
|
+
<Field label={t("users.name")} error={formErrors.name} required>
|
|
253
253
|
<Input
|
|
254
254
|
value={form.name}
|
|
255
255
|
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
|
256
256
|
/>
|
|
257
257
|
</Field>
|
|
258
|
-
<Field label={t("users.lastName")} error={formErrors.lastName}>
|
|
258
|
+
<Field label={t("users.lastName")} error={formErrors.lastName} required>
|
|
259
259
|
<Input
|
|
260
260
|
value={form.lastName}
|
|
261
261
|
onChange={(e) => setForm({ ...form, lastName: e.target.value })}
|
|
@@ -354,6 +354,7 @@ export function ProfileScreen(): JSX.Element {
|
|
|
354
354
|
<Field
|
|
355
355
|
label={t("profile.currentPassword")}
|
|
356
356
|
error={errors.currentPassword?.message}
|
|
357
|
+
required
|
|
357
358
|
>
|
|
358
359
|
<PasswordField
|
|
359
360
|
error={!!errors.currentPassword}
|
|
@@ -363,6 +364,7 @@ export function ProfileScreen(): JSX.Element {
|
|
|
363
364
|
<Field
|
|
364
365
|
label={t("profile.newPassword")}
|
|
365
366
|
error={errors.newPassword?.message}
|
|
367
|
+
required
|
|
366
368
|
>
|
|
367
369
|
<PasswordField
|
|
368
370
|
error={!!errors.newPassword}
|
|
@@ -373,6 +375,7 @@ export function ProfileScreen(): JSX.Element {
|
|
|
373
375
|
<Field
|
|
374
376
|
label={t("profile.confirmPassword")}
|
|
375
377
|
error={errors.confirm?.message}
|
|
378
|
+
required
|
|
376
379
|
>
|
|
377
380
|
<PasswordField
|
|
378
381
|
error={!!errors.confirm}
|
|
@@ -250,7 +250,7 @@ export function GroupsPanel(): JSX.Element {
|
|
|
250
250
|
</DialogTitle>
|
|
251
251
|
</DialogHeader>
|
|
252
252
|
<DialogForm onSubmit={() => void save()}>
|
|
253
|
-
<Field label={t("users.name")}>
|
|
253
|
+
<Field label={t("users.name")} required>
|
|
254
254
|
<Input
|
|
255
255
|
value={form.name}
|
|
256
256
|
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
|
@@ -180,7 +180,7 @@ export function PermissionsPanel(): JSX.Element {
|
|
|
180
180
|
</DialogTitle>
|
|
181
181
|
</DialogHeader>
|
|
182
182
|
<DialogForm onSubmit={() => void save()}>
|
|
183
|
-
<Field label="Code (resource:action:scope)">
|
|
183
|
+
<Field label="Code (resource:action:scope)" required>
|
|
184
184
|
<Input
|
|
185
185
|
placeholder="users:read:any"
|
|
186
186
|
value={form.code}
|
|
@@ -201,7 +201,7 @@ export function RolesPanel(): JSX.Element {
|
|
|
201
201
|
</DialogHeader>
|
|
202
202
|
<DialogForm onSubmit={handleSubmit((values) => void save(values))}>
|
|
203
203
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
|
204
|
-
<Field label={t("rbac.roleName")} error={errors.name?.message}>
|
|
204
|
+
<Field label={t("rbac.roleName")} error={errors.name?.message} required>
|
|
205
205
|
<Input {...register("name")} />
|
|
206
206
|
</Field>
|
|
207
207
|
<Field
|
|
@@ -48,7 +48,7 @@ export function ForgotPasswordScreen(): JSX.Element {
|
|
|
48
48
|
<p className="text-sm text-muted-foreground">
|
|
49
49
|
{t("recovery.forgotHint")}
|
|
50
50
|
</p>
|
|
51
|
-
<Field label={t("auth.email")} error={errors.email?.message}>
|
|
51
|
+
<Field label={t("auth.email")} error={errors.email?.message} required>
|
|
52
52
|
<Input type="email" autoFocus {...register("email")} />
|
|
53
53
|
</Field>
|
|
54
54
|
<Button type="submit" size="lg" disabled={loading}>
|
|
@@ -159,6 +159,7 @@ export function ResetPasswordScreen(): JSX.Element {
|
|
|
159
159
|
<Field
|
|
160
160
|
label={t("recovery.newPassword")}
|
|
161
161
|
error={errors.newPassword?.message}
|
|
162
|
+
required
|
|
162
163
|
>
|
|
163
164
|
<PasswordField
|
|
164
165
|
error={!!errors.newPassword}
|
|
@@ -169,6 +170,7 @@ export function ResetPasswordScreen(): JSX.Element {
|
|
|
169
170
|
<Field
|
|
170
171
|
label={t("recovery.confirmPassword")}
|
|
171
172
|
error={errors.confirm?.message}
|
|
173
|
+
required
|
|
172
174
|
>
|
|
173
175
|
<PasswordField
|
|
174
176
|
error={!!errors.confirm}
|
|
@@ -135,7 +135,7 @@ export function UserFormDialog({
|
|
|
135
135
|
|
|
136
136
|
<DialogForm onSubmit={submit}>
|
|
137
137
|
<div className="flex max-h-[70vh] flex-col gap-4 overflow-y-auto px-1">
|
|
138
|
-
<Field label={t("users.name")} error={errors.name?.message}>
|
|
138
|
+
<Field label={t("users.name")} error={errors.name?.message} required>
|
|
139
139
|
<Input {...register("name")} />
|
|
140
140
|
</Field>
|
|
141
141
|
<Field label={t("users.lastName")} error={errors.lastName?.message}>
|
|
@@ -154,7 +154,7 @@ export function UserFormDialog({
|
|
|
154
154
|
</Field>
|
|
155
155
|
)}
|
|
156
156
|
/>
|
|
157
|
-
<Field label={t("users.email")} error={errors.email?.message}>
|
|
157
|
+
<Field label={t("users.email")} error={errors.email?.message} required>
|
|
158
158
|
<Input type="email" {...register("email")} />
|
|
159
159
|
</Field>
|
|
160
160
|
{!isEdit && (
|
|
@@ -169,6 +169,7 @@ export function UserFormDialog({
|
|
|
169
169
|
<Field
|
|
170
170
|
label={t("users.team")}
|
|
171
171
|
error={errors.groupId?.message}
|
|
172
|
+
required
|
|
172
173
|
>
|
|
173
174
|
<SimpleSelect
|
|
174
175
|
value={field.value ?? ""}
|
|
@@ -1,16 +1,11 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import {
|
|
4
|
-
useCallback,
|
|
5
|
-
useEffect,
|
|
6
|
-
useLayoutEffect,
|
|
7
|
-
useRef,
|
|
8
|
-
useSyncExternalStore,
|
|
9
|
-
} from "react";
|
|
3
|
+
import { useCallback, useEffect, useLayoutEffect, useRef } from "react";
|
|
10
4
|
|
|
11
5
|
import { safeStorage } from "#core/_utils/storage";
|
|
12
6
|
import { useIsHydrated } from "#core/hooks/use-is-hydrated";
|
|
13
7
|
import { useStoredValue } from "#core/hooks/use-stored-value";
|
|
8
|
+
import { readUrlParam, useUrlParam } from "#core/hooks/use-url-param";
|
|
14
9
|
|
|
15
10
|
const STORAGE_PREFIX = "tab:";
|
|
16
11
|
const DEFAULT_PARAM = "tab";
|
|
@@ -33,35 +28,6 @@ export interface UseTabStateResult<T extends string> {
|
|
|
33
28
|
setTab: (next: string) => void;
|
|
34
29
|
}
|
|
35
30
|
|
|
36
|
-
const readFromUrl = (param: string): string | null =>
|
|
37
|
-
new URLSearchParams(window.location.search).get(param);
|
|
38
|
-
|
|
39
|
-
/** Quem lê a querystring, para saber que ela mudou por aqui. */
|
|
40
|
-
const urlListeners = new Set<() => void>();
|
|
41
|
-
|
|
42
|
-
const subscribeUrl = (onChange: () => void): (() => void) => {
|
|
43
|
-
urlListeners.add(onChange);
|
|
44
|
-
// O "voltar" do navegador também troca a aba, e esse não passa por aqui.
|
|
45
|
-
window.addEventListener("popstate", onChange);
|
|
46
|
-
return () => {
|
|
47
|
-
urlListeners.delete(onChange);
|
|
48
|
-
window.removeEventListener("popstate", onChange);
|
|
49
|
-
};
|
|
50
|
-
};
|
|
51
|
-
|
|
52
|
-
/** Troca só o próprio parâmetro, preservando o resto — o `?filter=` da listagem mora ao lado. */
|
|
53
|
-
const writeToUrl = (param: string, value: string): void => {
|
|
54
|
-
const params = new URLSearchParams(window.location.search);
|
|
55
|
-
params.set(param, value);
|
|
56
|
-
window.history.replaceState(null, "", `${window.location.pathname}?${params.toString()}`);
|
|
57
|
-
|
|
58
|
-
// `replaceState` não dispara evento nenhum: sem este aviso, a tela ficaria
|
|
59
|
-
// na aba antiga até o próximo render por outro motivo.
|
|
60
|
-
for (const listener of urlListeners) {
|
|
61
|
-
listener();
|
|
62
|
-
}
|
|
63
|
-
};
|
|
64
|
-
|
|
65
31
|
/**
|
|
66
32
|
* Em qual aba a pessoa deixou a tela, por `storageKey`.
|
|
67
33
|
*
|
|
@@ -93,11 +59,7 @@ export function useTabState<T extends string>(
|
|
|
93
59
|
// dentro de um efeito: no primeiro render do Next não há `window`, e a
|
|
94
60
|
// leitura do cliente entra assim que ele hidrata, sem um quadro na aba
|
|
95
61
|
// errada.
|
|
96
|
-
const urlTab =
|
|
97
|
-
subscribeUrl,
|
|
98
|
-
() => readFromUrl(param),
|
|
99
|
-
() => null,
|
|
100
|
-
);
|
|
62
|
+
const [urlTab, writeUrlTab] = useUrlParam(param);
|
|
101
63
|
const storedTab = useStoredValue(`${STORAGE_PREFIX}${storageKey}`);
|
|
102
64
|
|
|
103
65
|
const isTab = (value: string | null): value is T =>
|
|
@@ -127,10 +89,10 @@ export function useTabState<T extends string>(
|
|
|
127
89
|
if (!hydrated) {
|
|
128
90
|
return;
|
|
129
91
|
}
|
|
130
|
-
if (tab !== undefined &&
|
|
131
|
-
|
|
92
|
+
if (tab !== undefined && readUrlParam(param) !== tab) {
|
|
93
|
+
writeUrlTab(tab);
|
|
132
94
|
}
|
|
133
|
-
}, [hydrated, tab, param]);
|
|
95
|
+
}, [hydrated, tab, param, writeUrlTab]);
|
|
134
96
|
|
|
135
97
|
const setTab = useCallback(
|
|
136
98
|
(next: string): void => {
|
|
@@ -139,10 +101,10 @@ export function useTabState<T extends string>(
|
|
|
139
101
|
}
|
|
140
102
|
|
|
141
103
|
// Escrever nos dois já avisa quem lê: a aba vem da URL e do storage.
|
|
142
|
-
|
|
104
|
+
writeUrlTab(next);
|
|
143
105
|
safeStorage.set(`${STORAGE_PREFIX}${storageKey}`, next);
|
|
144
106
|
},
|
|
145
|
-
[storageKey,
|
|
107
|
+
[storageKey, writeUrlTab],
|
|
146
108
|
);
|
|
147
109
|
|
|
148
110
|
return { tab, setTab };
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useCallback, useSyncExternalStore } from "react";
|
|
4
|
+
|
|
5
|
+
/** Quem lê a querystring, para saber que ela mudou por aqui. */
|
|
6
|
+
const listeners = new Set<() => void>();
|
|
7
|
+
|
|
8
|
+
const subscribe = (onChange: () => void): (() => void) => {
|
|
9
|
+
listeners.add(onChange);
|
|
10
|
+
// O "voltar" do navegador também troca o valor, e esse não passa por aqui.
|
|
11
|
+
window.addEventListener("popstate", onChange);
|
|
12
|
+
return () => {
|
|
13
|
+
listeners.delete(onChange);
|
|
14
|
+
window.removeEventListener("popstate", onChange);
|
|
15
|
+
};
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
/** O valor de um parâmetro da URL, ou `null` quando ele não está lá. */
|
|
19
|
+
export const readUrlParam = (name: string): string | null =>
|
|
20
|
+
new URLSearchParams(window.location.search).get(name);
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Troca só o próprio parâmetro, preservando o resto — o `?filter=` da listagem
|
|
24
|
+
* mora ao lado. `null` o remove.
|
|
25
|
+
*
|
|
26
|
+
* `replaceState`, e não `router.replace`: trocar um parâmetro não é navegação,
|
|
27
|
+
* e empilhar histórico faria o "voltar" percorrer valor por valor antes de sair
|
|
28
|
+
* da tela.
|
|
29
|
+
*/
|
|
30
|
+
export const writeUrlParam = (name: string, value: string | null): void => {
|
|
31
|
+
const params = new URLSearchParams(window.location.search);
|
|
32
|
+
|
|
33
|
+
if (value === null) {
|
|
34
|
+
params.delete(name);
|
|
35
|
+
} else {
|
|
36
|
+
params.set(name, value);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const query = params.toString();
|
|
40
|
+
window.history.replaceState(
|
|
41
|
+
null,
|
|
42
|
+
"",
|
|
43
|
+
query ? `${window.location.pathname}?${query}` : window.location.pathname,
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
// `replaceState` não dispara evento nenhum: sem este aviso, quem lê o
|
|
47
|
+
// parâmetro ficaria com o valor antigo até o próximo render por outro motivo.
|
|
48
|
+
for (const listener of listeners) {
|
|
49
|
+
listener();
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Um parâmetro da querystring como estado: `[valor, trocar]`.
|
|
55
|
+
*
|
|
56
|
+
* É o que faz o F5 voltar onde estava e transforma o estado em link
|
|
57
|
+
* compartilhável — o mês em exibição, a linha que um aviso aponta. Lido no
|
|
58
|
+
* próprio render, e não copiado para um estado num efeito: no primeiro render
|
|
59
|
+
* do Next não há `window`, e a leitura do cliente entra assim que ele hidrata,
|
|
60
|
+
* sem um quadro no valor errado.
|
|
61
|
+
*
|
|
62
|
+
* O servidor sempre enxerga `null`: quem tem padrão aplica na leitura
|
|
63
|
+
* (`value ?? padrao`), e o padrão vale nos dois lados.
|
|
64
|
+
*/
|
|
65
|
+
export function useUrlParam(
|
|
66
|
+
name: string,
|
|
67
|
+
): [value: string | null, set: (value: string | null) => void] {
|
|
68
|
+
const value = useSyncExternalStore(
|
|
69
|
+
subscribe,
|
|
70
|
+
() => readUrlParam(name),
|
|
71
|
+
() => null,
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
const set = useCallback(
|
|
75
|
+
(next: string | null): void => writeUrlParam(name, next),
|
|
76
|
+
[name],
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
return [value, set];
|
|
80
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -131,6 +131,7 @@ export type { UseTabStateOptions, UseTabStateResult } from "#core/hooks/use-tab-
|
|
|
131
131
|
export { useTabState } from "#core/hooks/use-tab-state";
|
|
132
132
|
export type { TableView, UseTableViewResult } from "#core/hooks/use-table-view";
|
|
133
133
|
export { useTableView } from "#core/hooks/use-table-view";
|
|
134
|
+
export { useUrlParam } from "#core/hooks/use-url-param";
|
|
134
135
|
// RequestOperation é enum (valor, não só tipo): sem ele o projeto não consegue
|
|
135
136
|
// pedir o toast padrão de criado/alterado/removido que o core já traduz.
|
|
136
137
|
export { RequestOperation, useRequest } from "#core/hooks/use-request";
|
package/src/styles/core.css
CHANGED
|
@@ -180,6 +180,54 @@
|
|
|
180
180
|
letter-spacing: -0.02em;
|
|
181
181
|
}
|
|
182
182
|
|
|
183
|
+
/*
|
|
184
|
+
* Barra de rolagem — uma só, em todo lugar.
|
|
185
|
+
*
|
|
186
|
+
* A nativa é grossa (15px no macOS/Windows), tem trilho pintado e ignora o
|
|
187
|
+
* raio do que está rolando: dentro de um menu de 18rem, ela ocupa um décimo
|
|
188
|
+
* da largura e corta o canto arredondado. Aqui ela vira uma pílula fina sem
|
|
189
|
+
* trilho, na cor do texto secundário — presente enquanto se rola, discreta
|
|
190
|
+
* quando parada.
|
|
191
|
+
*
|
|
192
|
+
* As duas declarações não se somam: `scrollbar-width`/`scrollbar-color` são
|
|
193
|
+
* o padrão (Firefox, Chrome 121+, Safari 18.4+) e, onde valem, o browser
|
|
194
|
+
* ignora os pseudo-elementos `::-webkit-scrollbar` abaixo — que ficam para
|
|
195
|
+
* o WebKit mais antigo. Manter as duas é o que faz a barra ser igual nos
|
|
196
|
+
* dois lados.
|
|
197
|
+
*
|
|
198
|
+
* A cor sai de `--muted-foreground`, então ela acompanha o tema sozinha: no
|
|
199
|
+
* escuro clareia, no claro escurece, sem uma segunda regra em `.dark`.
|
|
200
|
+
*/
|
|
201
|
+
* {
|
|
202
|
+
scrollbar-width: thin;
|
|
203
|
+
scrollbar-color: hsl(var(--muted-foreground) / 0.35) transparent;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
::-webkit-scrollbar {
|
|
207
|
+
width: 8px;
|
|
208
|
+
height: 8px;
|
|
209
|
+
}
|
|
210
|
+
::-webkit-scrollbar-track {
|
|
211
|
+
background: transparent;
|
|
212
|
+
}
|
|
213
|
+
/*
|
|
214
|
+
* A borda transparente com `background-clip: content-box` é o que desenha a
|
|
215
|
+
* folga dos dois lados da pílula: sem ela a barra encosta na borda do menu.
|
|
216
|
+
*/
|
|
217
|
+
::-webkit-scrollbar-thumb {
|
|
218
|
+
background-color: hsl(var(--muted-foreground) / 0.35);
|
|
219
|
+
border: 2px solid transparent;
|
|
220
|
+
background-clip: content-box;
|
|
221
|
+
border-radius: 9999px;
|
|
222
|
+
}
|
|
223
|
+
::-webkit-scrollbar-thumb:hover {
|
|
224
|
+
background-color: hsl(var(--muted-foreground) / 0.55);
|
|
225
|
+
}
|
|
226
|
+
/* O quadradinho onde as duas barras se encontram, que fica cinza por padrão. */
|
|
227
|
+
::-webkit-scrollbar-corner {
|
|
228
|
+
background: transparent;
|
|
229
|
+
}
|
|
230
|
+
|
|
183
231
|
/*
|
|
184
232
|
* Preenchimento automático (gerenciador de senhas). O `background-color` do
|
|
185
233
|
* campo preenchido é intocável por CSS de autor — o Chrome o declara com
|