@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.266",
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
- * The pane renders that dialog without moving focus into it, so focus sits
212
- * on the drawer control that opened it — now behind the dialog's backdrop.
213
- * Tab has to enter the dialog's ring rather than walk the controls the
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 { cn, type OverlayAnswers, useOverlayScope } from "@remit/ui";
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",