@remit/web-client 0.0.212 → 0.0.214

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.212",
3
+ "version": "0.0.214",
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": {
@@ -206,6 +206,39 @@ describe("Drawer's ring is what a reader can reach", () => {
206
206
 
207
207
  assert.equal(document.activeElement, named("Cancel"));
208
208
  });
209
+
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.
215
+ */
216
+ it("pulls focus behind a nested dialog's backdrop into its ring", () => {
217
+ const nestedDialog = createElement(
218
+ "div",
219
+ {
220
+ key: "nested",
221
+ role: "dialog",
222
+ "aria-modal": "true",
223
+ "aria-label": "Reclassify sender",
224
+ },
225
+ button("automated"),
226
+ button("Cancel"),
227
+ );
228
+
229
+ open(createElement(Fragment, null, button("trigger"), nestedDialog));
230
+ named("trigger").focus();
231
+
232
+ press("Tab");
233
+
234
+ assert.equal(document.activeElement, named("automated"));
235
+
236
+ named("trigger").focus();
237
+
238
+ press("Tab", true);
239
+
240
+ assert.equal(document.activeElement, named("Cancel"));
241
+ });
209
242
  });
210
243
 
211
244
  describe("Drawer gives focus back on the way out", () => {
@@ -73,25 +73,29 @@ export const Drawer = ({
73
73
  onClose();
74
74
  return;
75
75
  }
76
- // Wrap Tab at the ends of the drawer's ring: walking off the last
77
- // control cycles back to the first instead of landing on controls a
78
- // pointer cannot reach under the scrim (#747). Focus that is already
79
- // outside the drawer — an error banner, the fatal-error overlay — is
80
- // left alone; the drawer does not pull it back in.
76
+ // Trap Tab inside the drawer's ring: walking off either end cycles
77
+ // round, and focus that is in the drawer but outside the ring — a
78
+ // control behind a nested dialog's backdrop — is pulled in, so Tab
79
+ // never lands where a pointer cannot reach (#747). The trap only
80
+ // applies while the drawer already holds focus; an error banner or
81
+ // the fatal-error overlay above it keeps its own Tab (#970).
81
82
  if (event.key !== "Tab" || !drawerRef.current) return;
83
+ const active = document.activeElement;
84
+ if (!(active instanceof HTMLElement)) return;
85
+ if (!drawerRef.current.contains(active)) return;
82
86
  const ring = tabRing(drawerRef.current);
83
87
  if (ring.length === 0) return;
84
88
  const first = ring[0];
85
89
  const last = ring[ring.length - 1];
86
- const active = document.activeElement;
90
+ const inside = ring.includes(active);
87
91
  if (event.shiftKey) {
88
- if (active === first) {
92
+ if (!inside || active === first) {
89
93
  event.preventDefault();
90
94
  last.focus();
91
95
  }
92
96
  return;
93
97
  }
94
- if (active === last) {
98
+ if (!inside || active === last) {
95
99
  event.preventDefault();
96
100
  first.focus();
97
101
  }
@@ -88,17 +88,23 @@ const updateKey = systemOperationsGetSystemUpdateQueryKey();
88
88
  const BUDGET_MS =
89
89
  (APPLY_BUDGET_SECONDS + NEVER_CAME_BACK_MARGIN_SECONDS) * 1000;
90
90
 
91
- async function settle(dom: DomHarness): Promise<void> {
92
- for (let attempt = 0; attempt < 40; attempt += 1) {
91
+ /**
92
+ * Poll `ready` across flushes until it holds. A mocked answer still travels
93
+ * through the fetch seam's own promise chain — the request body, the responder,
94
+ * the response stream — so the render an assert reads is an unknown number of
95
+ * turns out, and a fixed count of them is a race under load. Waiting on the
96
+ * condition itself is what makes these tests deterministic.
97
+ */
98
+ async function waitFor(
99
+ dom: DomHarness,
100
+ ready: () => boolean,
101
+ what: string,
102
+ ): Promise<void> {
103
+ for (let attempt = 0; attempt < 200; attempt += 1) {
93
104
  await dom.flush();
94
- const state = dom.queryClient.getQueryState(updateKey);
95
- const done =
96
- state &&
97
- state.fetchStatus === "idle" &&
98
- (state.data !== undefined || state.error != null);
99
- if (done) {
100
- // The cache has settled; give React the turns to commit the render it
101
- // scheduled off that settle before the assert reads the DOM.
105
+ if (ready()) {
106
+ // The state is there; give React the turns to commit the render it
107
+ // scheduled off it before the assert reads the DOM.
102
108
  await dom.flush();
103
109
  await dom.wait(1);
104
110
  await dom.flush();
@@ -106,6 +112,29 @@ async function settle(dom: DomHarness): Promise<void> {
106
112
  }
107
113
  await dom.wait(1);
108
114
  }
115
+ throw new Error(`timed out waiting for ${what}`);
116
+ }
117
+
118
+ /** How many times the poll has answered, either way — it never goes backwards. */
119
+ function answers(dom: DomHarness): number {
120
+ const state = dom.queryClient.getQueryState(updateKey);
121
+ if (!state) return 0;
122
+ return state.dataUpdateCount + state.errorUpdateCount;
123
+ }
124
+
125
+ async function settle(dom: DomHarness): Promise<void> {
126
+ await waitFor(
127
+ dom,
128
+ () => {
129
+ const state = dom.queryClient.getQueryState(updateKey);
130
+ return (
131
+ state !== undefined &&
132
+ state.fetchStatus === "idle" &&
133
+ (state.data !== undefined || state.error != null)
134
+ );
135
+ },
136
+ "the update query to answer",
137
+ );
109
138
  }
110
139
 
111
140
  /**
@@ -152,18 +181,55 @@ async function startUpdate(
152
181
  await act(async () => {
153
182
  api().install("0.9.4");
154
183
  await dom.flush();
155
- await dom.wait(1);
184
+ });
185
+ await waitFor(
186
+ dom,
187
+ () => {
188
+ const surface = api().surface;
189
+ return surface.status === "ready" && surface.overlay.kind === "applying";
190
+ },
191
+ "the install to be accepted",
192
+ );
193
+ }
194
+
195
+ /** Re-poll and wait for the answer, whichever way it lands. */
196
+ async function retryConnection(
197
+ dom: DomHarness,
198
+ api: () => SelfUpdateApi,
199
+ ): Promise<void> {
200
+ const before = answers(dom);
201
+ await act(async () => {
202
+ api().onRetryConnection();
156
203
  await dom.flush();
157
204
  });
205
+ await waitFor(
206
+ dom,
207
+ () =>
208
+ dom.queryClient.getQueryState(updateKey)?.fetchStatus === "idle" &&
209
+ answers(dom) > before,
210
+ "the re-poll to answer",
211
+ );
158
212
  }
159
213
 
160
214
  async function press(dom: DomHarness, api: () => SelfUpdateApi): Promise<void> {
215
+ const before = answers(dom);
161
216
  await act(async () => {
162
217
  api().onCheck();
163
218
  await dom.flush();
164
- await dom.wait(1);
165
- await dom.flush();
166
219
  });
220
+ // The press fires a raw SDK call: on success it refetches, so the poll
221
+ // answers again; on failure the reason lands on the pane.
222
+ await waitFor(
223
+ dom,
224
+ () => {
225
+ const surface = api().surface;
226
+ return (
227
+ answers(dom) > before ||
228
+ (surface.status === "ready" && surface.section.status === "checkFailed")
229
+ );
230
+ },
231
+ "the pressed check to be requested",
232
+ );
167
233
  }
168
234
 
169
235
  async function renderSurface(
@@ -298,12 +364,7 @@ describe("SelfUpdateOverlay — the blocking screen", () => {
298
364
  await startUpdate(dom, api);
299
365
 
300
366
  failing = true;
301
- await act(async () => {
302
- api().onRetryConnection();
303
- await dom.flush();
304
- await dom.wait(1);
305
- await dom.flush();
306
- });
367
+ await retryConnection(dom, api);
307
368
 
308
369
  const surface = api().surface;
309
370
  assert.equal(
@@ -329,12 +390,7 @@ describe("SelfUpdateOverlay — the blocking screen", () => {
329
390
  const realNow = Date.now;
330
391
  Date.now = () => realNow() + BUDGET_MS + 60_000;
331
392
  try {
332
- await act(async () => {
333
- api().onRetryConnection();
334
- await dom.flush();
335
- await dom.wait(1);
336
- await dom.flush();
337
- });
393
+ await retryConnection(dom, api);
338
394
  assert.doesNotMatch(dom.html(), /Installing Remit 0\.9\.4/);
339
395
  assert.match(dom.html(), /has not answered since the restart/);
340
396
  assert.match(dom.html(), /remit logs/);
@@ -360,12 +416,7 @@ describe("SelfUpdateOverlay — the blocking screen", () => {
360
416
  await startUpdate(dom, api);
361
417
 
362
418
  finished = true;
363
- await act(async () => {
364
- api().onRetryConnection();
365
- await dom.flush();
366
- await dom.wait(1);
367
- await dom.flush();
368
- });
419
+ await retryConnection(dom, api);
369
420
 
370
421
  const surface = api().surface;
371
422
  assert.equal(
@@ -471,10 +522,7 @@ describe("useSystemUpdate — actions", () => {
471
522
  await settle(dom);
472
523
  const afterMount = calls;
473
524
 
474
- await act(async () => {
475
- api().onRetryConnection();
476
- await dom.flush();
477
- });
525
+ await retryConnection(dom, api);
478
526
 
479
527
  assert.equal(calls > afterMount, true);
480
528
  });