@remit/web-client 0.0.266 → 0.0.267
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": "@remit/web-client",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.267",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Remit web client, published as composable primitives — the app shell, auth shells, and runtime config. A distributor imports what it composes and bundles it.",
|
|
6
6
|
"exports": {
|
|
@@ -208,10 +208,9 @@ describe("Drawer's ring is what a reader can reach", () => {
|
|
|
208
208
|
});
|
|
209
209
|
|
|
210
210
|
/**
|
|
211
|
-
*
|
|
212
|
-
*
|
|
213
|
-
*
|
|
214
|
-
* backdrop covers.
|
|
211
|
+
* A dialog that does not take the shared trap leaves focus on the drawer
|
|
212
|
+
* control that opened it — now behind that dialog's backdrop. Tab has to
|
|
213
|
+
* enter the dialog's ring rather than walk the controls the backdrop covers.
|
|
215
214
|
*/
|
|
216
215
|
it("pulls focus behind a nested dialog's backdrop into its ring", () => {
|
|
217
216
|
const nestedDialog = createElement(
|
|
@@ -1,38 +1,12 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
cn,
|
|
3
|
+
type OverlayAnswers,
|
|
4
|
+
useModalFocus,
|
|
5
|
+
useOverlayScope,
|
|
6
|
+
} from "@remit/ui";
|
|
2
7
|
import { X } from "lucide-react";
|
|
3
8
|
import { type ReactNode, useEffect, useRef } from "react";
|
|
4
9
|
|
|
5
|
-
const FOCUSABLE_SELECTOR =
|
|
6
|
-
"a[href], button, input, select, textarea, [tabindex]";
|
|
7
|
-
|
|
8
|
-
const isTabbable = (element: HTMLElement): boolean => {
|
|
9
|
-
if (element.tabIndex < 0) return false;
|
|
10
|
-
if (element.hasAttribute("disabled")) return false;
|
|
11
|
-
if (element.getAttribute("aria-disabled") === "true") return false;
|
|
12
|
-
if (element.closest("[inert], [hidden], [aria-hidden='true']")) return false;
|
|
13
|
-
const style = getComputedStyle(element);
|
|
14
|
-
return style.display !== "none" && style.visibility !== "hidden";
|
|
15
|
-
};
|
|
16
|
-
|
|
17
|
-
/**
|
|
18
|
-
* The elements Tab may reach, in the order it reaches them.
|
|
19
|
-
*
|
|
20
|
-
* A modal opened from inside the drawer takes the ring with it: the
|
|
21
|
-
* intelligence pane's reclassify dialog renders under the drawer's own panel,
|
|
22
|
-
* so a ring scoped to the panel puts its Cancel last and leaves every control
|
|
23
|
-
* behind that dialog's backdrop in the cycle — Tab walks onto buttons no
|
|
24
|
-
* pointer can reach, which is the bug the trap exists to prevent.
|
|
25
|
-
*/
|
|
26
|
-
const tabRing = (panel: HTMLElement): HTMLElement[] => {
|
|
27
|
-
const nested = panel.querySelectorAll<HTMLElement>(
|
|
28
|
-
'[role="dialog"][aria-modal="true"]',
|
|
29
|
-
);
|
|
30
|
-
const root = nested[nested.length - 1] ?? panel;
|
|
31
|
-
return [...root.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR)].filter(
|
|
32
|
-
isTabbable,
|
|
33
|
-
);
|
|
34
|
-
};
|
|
35
|
-
|
|
36
10
|
interface DrawerProps {
|
|
37
11
|
isOpen: boolean;
|
|
38
12
|
onClose: () => void;
|
|
@@ -68,7 +42,6 @@ export const Drawer = ({
|
|
|
68
42
|
answers,
|
|
69
43
|
}: DrawerProps) => {
|
|
70
44
|
const drawerRef = useRef<HTMLDivElement>(null);
|
|
71
|
-
const previouslyFocusedRef = useRef<HTMLElement | null>(null);
|
|
72
45
|
|
|
73
46
|
useOverlayScope({
|
|
74
47
|
id: "drawer",
|
|
@@ -76,53 +49,14 @@ export const Drawer = ({
|
|
|
76
49
|
answers: { back: onClose, ...answers },
|
|
77
50
|
});
|
|
78
51
|
|
|
52
|
+
useModalFocus(drawerRef, isOpen);
|
|
53
|
+
|
|
79
54
|
useEffect(() => {
|
|
80
55
|
if (!isOpen) return;
|
|
81
|
-
|
|
82
|
-
previouslyFocusedRef.current = document.activeElement as HTMLElement | null;
|
|
83
|
-
|
|
84
|
-
const handleKey = (event: KeyboardEvent) => {
|
|
85
|
-
// Trap Tab inside the drawer's ring: walking off either end cycles
|
|
86
|
-
// round, and focus that is in the drawer but outside the ring — a
|
|
87
|
-
// control behind a nested dialog's backdrop — is pulled in, so Tab
|
|
88
|
-
// never lands where a pointer cannot reach (#747). The trap only
|
|
89
|
-
// applies while the drawer already holds focus; an error banner or
|
|
90
|
-
// the fatal-error overlay above it keeps its own Tab (#970).
|
|
91
|
-
if (event.key !== "Tab" || !drawerRef.current) return;
|
|
92
|
-
const active = document.activeElement;
|
|
93
|
-
if (!(active instanceof HTMLElement)) return;
|
|
94
|
-
if (!drawerRef.current.contains(active)) return;
|
|
95
|
-
const ring = tabRing(drawerRef.current);
|
|
96
|
-
if (ring.length === 0) return;
|
|
97
|
-
const first = ring[0];
|
|
98
|
-
const last = ring[ring.length - 1];
|
|
99
|
-
const inside = ring.includes(active);
|
|
100
|
-
if (event.shiftKey) {
|
|
101
|
-
if (!inside || active === first) {
|
|
102
|
-
event.preventDefault();
|
|
103
|
-
last.focus();
|
|
104
|
-
}
|
|
105
|
-
return;
|
|
106
|
-
}
|
|
107
|
-
if (!inside || active === last) {
|
|
108
|
-
event.preventDefault();
|
|
109
|
-
first.focus();
|
|
110
|
-
}
|
|
111
|
-
};
|
|
112
|
-
|
|
113
|
-
document.addEventListener("keydown", handleKey);
|
|
114
|
-
|
|
115
|
-
// Move focus into the drawer
|
|
116
|
-
if (drawerRef.current) tabRing(drawerRef.current)[0]?.focus();
|
|
117
|
-
|
|
118
|
-
// Lock body scroll
|
|
119
56
|
const previousOverflow = document.body.style.overflow;
|
|
120
57
|
document.body.style.overflow = "hidden";
|
|
121
|
-
|
|
122
58
|
return () => {
|
|
123
|
-
document.removeEventListener("keydown", handleKey);
|
|
124
59
|
document.body.style.overflow = previousOverflow;
|
|
125
|
-
previouslyFocusedRef.current?.focus();
|
|
126
60
|
};
|
|
127
61
|
}, [isOpen]);
|
|
128
62
|
|
|
@@ -150,6 +84,7 @@ export const Drawer = ({
|
|
|
150
84
|
{/* Drawer panel */}
|
|
151
85
|
<div
|
|
152
86
|
ref={drawerRef}
|
|
87
|
+
tabIndex={-1}
|
|
153
88
|
className={cn(
|
|
154
89
|
"safe-area-frame absolute top-0 bottom-0 bg-canvas border-line shadow-xl flex flex-col",
|
|
155
90
|
widthClassName,
|
|
@@ -8,9 +8,10 @@ import {
|
|
|
8
8
|
type SimilarMessageLinkComponent,
|
|
9
9
|
type SimilarState,
|
|
10
10
|
type ThreadCategory,
|
|
11
|
+
useModalFocus,
|
|
11
12
|
} from "@remit/ui";
|
|
12
13
|
import { Sparkles } from "lucide-react";
|
|
13
|
-
import { useCallback, useState } from "react";
|
|
14
|
+
import { useCallback, useRef, useState } from "react";
|
|
14
15
|
import { useIntelligenceData } from "@/hooks/useIntelligenceData";
|
|
15
16
|
import { useReportSpam } from "@/hooks/useReportSpam";
|
|
16
17
|
import { useUpdateAddressFlags } from "@/hooks/useUpdateAddressFlags";
|
|
@@ -170,6 +171,9 @@ function ReclassifyDialog({
|
|
|
170
171
|
onSelect: (category: CategoryOverride) => void;
|
|
171
172
|
onCancel: () => void;
|
|
172
173
|
}) {
|
|
174
|
+
const dialogRef = useRef<HTMLDivElement>(null);
|
|
175
|
+
useModalFocus(dialogRef, isOpen);
|
|
176
|
+
|
|
173
177
|
if (!isOpen) return null;
|
|
174
178
|
return (
|
|
175
179
|
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
|
@@ -179,9 +183,11 @@ function ReclassifyDialog({
|
|
|
179
183
|
className="backdrop-blur-sm"
|
|
180
184
|
/>
|
|
181
185
|
<div
|
|
186
|
+
ref={dialogRef}
|
|
182
187
|
role="dialog"
|
|
183
188
|
aria-modal="true"
|
|
184
189
|
aria-label="Reclassify sender"
|
|
190
|
+
tabIndex={-1}
|
|
185
191
|
className="relative z-10 w-full max-w-sm rounded-sm border border-line bg-surface p-6 shadow-lg"
|
|
186
192
|
>
|
|
187
193
|
<h2 className="text-lg font-semibold">Reclassify this sender</h2>
|
|
@@ -3,10 +3,11 @@ import {
|
|
|
3
3
|
DialogBackdrop,
|
|
4
4
|
Kbd,
|
|
5
5
|
KEY_HINT_GROUPS,
|
|
6
|
+
useModalFocus,
|
|
6
7
|
useOverlayScope,
|
|
7
8
|
} from "@remit/ui";
|
|
8
9
|
import { X } from "lucide-react";
|
|
9
|
-
import { Fragment } from "react";
|
|
10
|
+
import { Fragment, useRef } from "react";
|
|
10
11
|
|
|
11
12
|
interface KeyboardShortcutsModalProps {
|
|
12
13
|
isOpen: boolean;
|
|
@@ -22,11 +23,14 @@ export const KeyboardShortcutsModal = ({
|
|
|
22
23
|
isOpen,
|
|
23
24
|
onClose,
|
|
24
25
|
}: KeyboardShortcutsModalProps) => {
|
|
26
|
+
const modalRef = useRef<HTMLDivElement>(null);
|
|
27
|
+
|
|
25
28
|
useOverlayScope({
|
|
26
29
|
id: "shortcuts",
|
|
27
30
|
open: isOpen,
|
|
28
31
|
answers: { back: onClose },
|
|
29
32
|
});
|
|
33
|
+
useModalFocus(modalRef, isOpen);
|
|
30
34
|
|
|
31
35
|
if (!isOpen) return null;
|
|
32
36
|
|
|
@@ -40,9 +44,11 @@ export const KeyboardShortcutsModal = ({
|
|
|
40
44
|
|
|
41
45
|
{/* Modal */}
|
|
42
46
|
<div
|
|
47
|
+
ref={modalRef}
|
|
43
48
|
role="dialog"
|
|
44
49
|
aria-modal="true"
|
|
45
50
|
aria-label="Keyboard shortcuts"
|
|
51
|
+
tabIndex={-1}
|
|
46
52
|
className={cn(
|
|
47
53
|
"relative z-10 max-h-[85vh] w-full max-w-2xl overflow-y-auto",
|
|
48
54
|
"rounded-lg border border-line bg-surface shadow-lg",
|