@remit/web-client 0.0.234 → 0.0.235

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.234",
3
+ "version": "0.0.235",
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": {
@@ -233,6 +233,24 @@ export const DryRunReportPhone: Story = {
233
233
  ),
234
234
  };
235
235
 
236
+ /**
237
+ * A household's worth of rules and senders, on the 1512×827 laptop where this
238
+ * report was found running off the bottom of the screen with "Import n changes"
239
+ * unreachable below it (#1021). The report scrolls inside the card; the
240
+ * verdict summary at the top and the import button at the bottom both stay put.
241
+ */
242
+ export const DryRunReportLongShortViewport: Story = {
243
+ globals: { viewport: { value: "laptopShort" } },
244
+ render: () => (
245
+ <StepDryRunReport
246
+ sections={groupReportSections(
247
+ Array.from({ length: 6 }, () => dryRunReport.items).flat(),
248
+ )}
249
+ fileName={CONFIG_FILE_NAME}
250
+ />
251
+ ),
252
+ };
253
+
236
254
  const HELD =
237
255
  "2 accounts, 14 rules and 31 flagged senders are set up here already.";
238
256
 
@@ -0,0 +1,216 @@
1
+ /**
2
+ * A report longer than the screen still leaves the reader somewhere to press.
3
+ *
4
+ * On a 1512×827 laptop the dry run of a real configuration drew taller than the
5
+ * viewport. The wizard sat in a fixed box with nothing scrolling, so "Import n
6
+ * changes" was rendered below the fold with no way to reach it and the import
7
+ * could not be finished at all (#1021).
8
+ *
9
+ * The shape that fixes it is structural, and structure is what this file holds:
10
+ * the growable part of a step lives in its own scroll box, the title and the
11
+ * footer actions sit outside that box, and the shell is capped to the viewport
12
+ * so nothing can push the footer off it. jsdom has no layout engine, so the
13
+ * pixels are pinned by the "long report, short viewport" Storybook stories
14
+ * instead — what is asserted here is the arrangement those stories depend on,
15
+ * which is the part a refactor silently takes away.
16
+ */
17
+
18
+ import assert from "node:assert/strict";
19
+ import { afterEach, describe, it } from "node:test";
20
+ import { createElement } from "react";
21
+ import type { ReportSection } from "../../lib/config-import";
22
+ import { createDomHarness, type DomHarness } from "../../test-support/dom";
23
+ import type { ImportedAccount } from "./steps";
24
+ import { StepCredentialsOverview, StepDryRunReport } from "./steps";
25
+
26
+ let harness: DomHarness | undefined;
27
+
28
+ afterEach(() => {
29
+ harness?.close();
30
+ harness = undefined;
31
+ });
32
+
33
+ const LONG_REPORT: ReportSection[] = [
34
+ {
35
+ id: "accounts",
36
+ title: "Accounts",
37
+ entries: Array.from({ length: 40 }, (_, index) => ({
38
+ id: `accounts-${index}`,
39
+ label: `mailbox-${index}@example.test`,
40
+ verdict: "created" as const,
41
+ })),
42
+ },
43
+ {
44
+ id: "filters",
45
+ title: "Rules & filters",
46
+ entries: Array.from({ length: 40 }, (_, index) => ({
47
+ id: `filters-${index}`,
48
+ label: `Rule ${index}`,
49
+ verdict: "skipped" as const,
50
+ reason: "The folder this rule files into is not here yet.",
51
+ })),
52
+ },
53
+ ];
54
+
55
+ const MANY_ACCOUNTS: ImportedAccount[] = Array.from(
56
+ { length: 30 },
57
+ (_, index) => ({
58
+ accountId: `acct-${index}`,
59
+ address: `mailbox-${index}@example.test`,
60
+ displayName: `Mailbox ${index}`,
61
+ connector: "imap" as const,
62
+ server: "imap.example.test",
63
+ state: "needed" as const,
64
+ }),
65
+ );
66
+
67
+ const classes = (element: Element): string[] =>
68
+ (element.getAttribute("class") ?? "").split(/\s+/).filter(Boolean);
69
+
70
+ const shellOf = (mounted: DomHarness): Element => {
71
+ const body = mounted.query("[data-testid=wizard-body]");
72
+ assert.ok(body, "the step renders a wizard body region");
73
+ const shell = body.closest("div.h-dvh");
74
+ assert.ok(shell, "the wizard body sits inside the shell root");
75
+ return shell;
76
+ };
77
+
78
+ const FOCUSABLE =
79
+ 'a[href], button, input, select, textarea, [tabindex]:not([tabindex="-1"])';
80
+
81
+ describe("a wizard step longer than the viewport (#1021)", () => {
82
+ it("puts the whole report inside the scroll box and the footer outside it", () => {
83
+ harness = createDomHarness({ viewportWidth: 1512 });
84
+ harness.render(
85
+ createElement(StepDryRunReport, {
86
+ sections: LONG_REPORT,
87
+ fileName: "reader-config.example.json",
88
+ }),
89
+ );
90
+
91
+ const body = harness.query("[data-testid=wizard-body]");
92
+ const footer = harness.query("[data-testid=wizard-footer]");
93
+ assert.ok(body && footer);
94
+
95
+ // The rail above the card renders a list item per step, so the count is
96
+ // the assertion: all 80 report rows are inside the scroll box, and a row
97
+ // left outside it would show up as a shortfall here.
98
+ assert.equal(
99
+ body.querySelectorAll("li").length,
100
+ 80,
101
+ "every report row grows inside the scroll box",
102
+ );
103
+
104
+ assert.ok(
105
+ !body.contains(footer),
106
+ "the footer sits outside the scroll box, so the report cannot scroll it away",
107
+ );
108
+ const primary = harness.byText("button", "Import");
109
+ assert.ok(
110
+ footer.contains(primary),
111
+ "the primary action lives in the footer",
112
+ );
113
+ });
114
+
115
+ it("scrolls the body and never the shell", () => {
116
+ harness = createDomHarness({ viewportWidth: 1512 });
117
+ harness.render(
118
+ createElement(StepDryRunReport, {
119
+ sections: LONG_REPORT,
120
+ fileName: "reader-config.example.json",
121
+ }),
122
+ );
123
+
124
+ const body = harness.query("[data-testid=wizard-body]");
125
+ assert.ok(body);
126
+ const bodyClasses = classes(body);
127
+ assert.ok(
128
+ bodyClasses.includes("overflow-y-auto"),
129
+ "the body region is the scrolling element",
130
+ );
131
+ assert.ok(
132
+ bodyClasses.includes("min-h-0"),
133
+ "the body region can shrink below its content, or it never scrolls",
134
+ );
135
+
136
+ const shell = shellOf(harness);
137
+ assert.ok(
138
+ classes(shell).includes("overflow-hidden"),
139
+ "the shell root does not scroll — the footer would ride out of view with it",
140
+ );
141
+ assert.deepEqual(
142
+ harness
143
+ .queryAll('[class*="overflow-y-auto"]')
144
+ .map((element) => element.getAttribute("data-testid")),
145
+ ["wizard-body"],
146
+ "exactly one region scrolls, and it is the one holding the growable content",
147
+ );
148
+ });
149
+
150
+ it("caps the shell at the viewport rather than growing past it", () => {
151
+ harness = createDomHarness({ viewportWidth: 1512 });
152
+ harness.render(
153
+ createElement(StepDryRunReport, {
154
+ sections: LONG_REPORT,
155
+ fileName: "reader-config.example.json",
156
+ }),
157
+ );
158
+
159
+ const shellClasses = classes(shellOf(harness));
160
+ assert.ok(
161
+ shellClasses.includes("h-dvh"),
162
+ "the shell is exactly the viewport, so no content length can extend it",
163
+ );
164
+ assert.ok(
165
+ !shellClasses.some((token) => token.startsWith("min-h-")),
166
+ "a minimum height would let the content push the shell past the fold again",
167
+ );
168
+ });
169
+
170
+ it("keeps the footer action last in the tab order", () => {
171
+ harness = createDomHarness({ viewportWidth: 1512 });
172
+ harness.render(
173
+ createElement(StepDryRunReport, {
174
+ sections: LONG_REPORT,
175
+ fileName: "reader-config.example.json",
176
+ }),
177
+ );
178
+
179
+ const footer = harness.query("[data-testid=wizard-footer]");
180
+ assert.ok(footer);
181
+ const focusable = harness.queryAll(FOCUSABLE);
182
+ assert.ok(focusable.length >= 2, "the step has controls to tab through");
183
+ assert.ok(
184
+ focusable.every((element) => {
185
+ const order = element.getAttribute("tabindex");
186
+ return order === null || Number.parseInt(order, 10) <= 0;
187
+ }),
188
+ "nothing claims a positive tabindex, so DOM order is the tab order",
189
+ );
190
+ const last = focusable[focusable.length - 1];
191
+ assert.ok(
192
+ last && footer.contains(last),
193
+ "tabbing forward through the report ends on the footer action",
194
+ );
195
+ });
196
+
197
+ it("holds the same shape for a long list of accounts needing credentials", () => {
198
+ harness = createDomHarness({ viewportWidth: 1512 });
199
+ harness.render(
200
+ createElement(StepCredentialsOverview, { accounts: MANY_ACCOUNTS }),
201
+ );
202
+
203
+ const body = harness.query("[data-testid=wizard-body]");
204
+ const footer = harness.query("[data-testid=wizard-footer]");
205
+ assert.ok(body && footer);
206
+ assert.equal(
207
+ body.querySelectorAll("li").length,
208
+ MANY_ACCOUNTS.length,
209
+ "the account list grows inside the scroll box",
210
+ );
211
+ assert.ok(
212
+ footer.contains(harness.byText("button", "Finish later")),
213
+ "the way on stays pinned however many accounts the file carried",
214
+ );
215
+ });
216
+ });