@remit/ui 0.0.122 → 0.0.123
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/app-shell-slotted.tsx +5 -1
- package/src/components/compose-body.language.test.ts +236 -0
- package/src/components/compose-body.stories.tsx +26 -0
- package/src/components/compose-form-shell.render.test.ts +59 -0
- package/src/components/compose-form-shell.tsx +36 -10
- package/src/components/email-frame-css.ts +110 -29
- package/src/components/isolated-email-frame.render.test.ts +174 -36
- package/src/components/isolated-email-frame.stories.tsx +518 -33
- package/src/components/isolated-email-frame.tsx +58 -135
- package/src/components/message-body-view.stories.tsx +200 -2
- package/src/components/message-body-view.tsx +82 -33
- package/src/components/mobile-reading-pane.tsx +2 -1
- package/src/components/reading-pane.stories.tsx +138 -3
- package/src/components/reading-pane.tsx +35 -26
- package/src/components/resizable.tsx +42 -0
- package/src/components/slide-panel.tsx +7 -3
- package/src/index.ts +8 -1
- package/src/lib/compose-language.test.ts +17 -0
- package/src/lib/compose-language.ts +17 -6
- package/src/lib/detect-compose-language.test.ts +18 -0
- package/src/lib/email-layout-clamp.test.ts +30 -0
- package/src/lib/email-layout-clamp.ts +17 -0
- package/src/lib/email-sanitizer.test.ts +153 -0
- package/src/lib/email-sanitizer.ts +165 -0
- package/src/lib/keymap.test.ts +16 -0
- package/src/lib/keymap.ts +16 -4
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
import type { Decorator, Meta, StoryObj } from "@storybook/react-vite";
|
|
2
|
+
import { expect, waitFor } from "storybook/test";
|
|
3
|
+
import { generateLayoutClampCSS } from "../lib/email-layout-clamp.js";
|
|
2
4
|
import { IsolatedEmailFrame } from "./isolated-email-frame.js";
|
|
3
5
|
|
|
4
6
|
/**
|
|
5
|
-
* `IsolatedEmailFrame` renders sanitized email HTML in a sandboxed iframe that
|
|
6
|
-
*
|
|
7
|
+
* `IsolatedEmailFrame` renders sanitized email HTML in a sandboxed iframe that is
|
|
8
|
+
* exactly as wide as the box holding it and isolates the email's CSS from the
|
|
9
|
+
* app.
|
|
7
10
|
*
|
|
8
11
|
* The component receives HTML that already carries the sanitizer's layout-clamp
|
|
9
12
|
* `<style>` block, so these stories prepend the same clamp CSS to each fixture
|
|
@@ -12,28 +15,18 @@ import { IsolatedEmailFrame } from "./isolated-email-frame.js";
|
|
|
12
15
|
* overflowed a phone — rendered at a phone width and at a desktop
|
|
13
16
|
* reading-column width.
|
|
14
17
|
*
|
|
15
|
-
*
|
|
16
|
-
* the
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
* query, not the fixed-width decorator. The `computeFitScale` unit test pins the
|
|
21
|
-
* scaling policy independently of the viewport.
|
|
18
|
+
* The app's layout never reacts to what is inside the frame. Mail that can
|
|
19
|
+
* reflow does, against the pane's width; mail that genuinely cannot — a table
|
|
20
|
+
* with its own `min-width`, an image the same, a `pre` the author pinned —
|
|
21
|
+
* scrolls inside the document, and neither the pane nor the page moves sideways
|
|
22
|
+
* at any width.
|
|
22
23
|
*/
|
|
23
24
|
|
|
24
|
-
//
|
|
25
|
-
//
|
|
26
|
-
//
|
|
27
|
-
//
|
|
28
|
-
const LAYOUT_CLAMP_CSS = `<style
|
|
29
|
-
html, body { margin: 0; padding: 0; max-width: 100%; }
|
|
30
|
-
body { overflow-wrap: anywhere; word-break: break-word; }
|
|
31
|
-
img, video, iframe, svg, canvas { max-width: 100% !important; height: auto; }
|
|
32
|
-
table { max-width: 100% !important; table-layout: auto; }
|
|
33
|
-
td, th { max-width: 100% !important; }
|
|
34
|
-
* { min-width: 0; }
|
|
35
|
-
pre, code { white-space: pre-wrap; overflow-wrap: anywhere; word-break: break-word; }
|
|
36
|
-
</style>`;
|
|
25
|
+
// The sanitizer's own clamp — clamps wide author markup (fixed-width
|
|
26
|
+
// tables/cells, oversized media) to the frame width and wraps long unbroken
|
|
27
|
+
// tokens. In the app the sanitizer prepends it; here the fixtures prepend the
|
|
28
|
+
// same generated stylesheet, so a rule that changes there changes here.
|
|
29
|
+
const LAYOUT_CLAMP_CSS = `<style>${generateLayoutClampCSS()}</style>`;
|
|
37
30
|
|
|
38
31
|
const HERO = `data:image/svg+xml;utf8,${encodeURIComponent(
|
|
39
32
|
`<svg xmlns="http://www.w3.org/2000/svg" width="600" height="200" viewBox="0 0 600 200">
|
|
@@ -108,18 +101,109 @@ const PLAIN = `${LAYOUT_CLAMP_CSS}
|
|
|
108
101
|
</div>
|
|
109
102
|
`;
|
|
110
103
|
|
|
104
|
+
// Mail that genuinely cannot fit: an inline `min-width` on the table outranks
|
|
105
|
+
// the clamp's `* { min-width: 0 }`, so 1200px of columns stay 1200px wide and
|
|
106
|
+
// the email's own document is what the reader scrolls to see the rest.
|
|
107
|
+
const WIDE_TABLE = `${LAYOUT_CLAMP_CSS}
|
|
108
|
+
<div style="font-family: Helvetica, Arial, sans-serif; color:#1a1a1a;">
|
|
109
|
+
<h1 style="font-size:20px;margin:0 0 12px;">Q2 regional breakdown</h1>
|
|
110
|
+
<table cellpadding="8" cellspacing="0" style="min-width:1200px;border-collapse:collapse;">
|
|
111
|
+
<tr style="background:#efefef;">
|
|
112
|
+
<th style="min-width:200px;text-align:left;">Region</th>
|
|
113
|
+
<th style="min-width:200px;text-align:left;">Pipeline</th>
|
|
114
|
+
<th style="min-width:200px;text-align:left;">Closed won</th>
|
|
115
|
+
<th style="min-width:200px;text-align:left;">Closed lost</th>
|
|
116
|
+
<th style="min-width:200px;text-align:left;">Forecast</th>
|
|
117
|
+
<th style="min-width:200px;text-align:left;">Owner</th>
|
|
118
|
+
</tr>
|
|
119
|
+
<tr>
|
|
120
|
+
<td>Benelux</td><td>€1.2M</td><td>€480k</td><td>€120k</td><td>€1.6M</td><td>Sanne de Vries</td>
|
|
121
|
+
</tr>
|
|
122
|
+
<tr style="background:#f8f8f8;">
|
|
123
|
+
<td>DACH</td><td>€2.4M</td><td>€910k</td><td>€300k</td><td>€3.1M</td><td>Jonas Brandt</td>
|
|
124
|
+
</tr>
|
|
125
|
+
</table>
|
|
126
|
+
<p>Full detail in the attached sheet.</p>
|
|
127
|
+
</div>
|
|
128
|
+
`;
|
|
129
|
+
|
|
130
|
+
// Mail that declares nothing: no background, no padding, no width. The frame
|
|
131
|
+
// supplies the ground — the reading pane's own colour — and the breathing room
|
|
132
|
+
// inside it, so the surface reaches both pane edges and only the text is inset.
|
|
133
|
+
const BARE_MAIL = `${LAYOUT_CLAMP_CSS}
|
|
134
|
+
<div>
|
|
135
|
+
<p>Hoi allemaal,</p>
|
|
136
|
+
<p>De repetitie van donderdag gaat door. We beginnen met het nieuwe stuk en
|
|
137
|
+
repeteren om 20.00 uur verder aan het programma voor het najaarsconcert.</p>
|
|
138
|
+
<p>Groeten,<br>Ingrid</p>
|
|
139
|
+
</div>
|
|
140
|
+
`;
|
|
141
|
+
|
|
142
|
+
// The same mail with an author `nowrap`: the clamp wraps it rather than leaving
|
|
143
|
+
// a paragraph that reads only by dragging the email sideways.
|
|
144
|
+
const NOWRAP_MAIL = `${LAYOUT_CLAMP_CSS}
|
|
145
|
+
<div style="white-space:nowrap">
|
|
146
|
+
<p>Hoi allemaal,</p>
|
|
147
|
+
<p>De repetitie van donderdag gaat door. We beginnen met het nieuwe stuk en repeteren om 20.00 uur verder aan het programma voor het najaarsconcert.</p>
|
|
148
|
+
<p>Groeten,<br>Ingrid</p>
|
|
149
|
+
</div>
|
|
150
|
+
`;
|
|
151
|
+
|
|
152
|
+
// The same nowrap as Outlook and the older generators emit it: uppercase, in an
|
|
153
|
+
// uppercase attribute. A case-sensitive attribute-value match sails past this
|
|
154
|
+
// one and pins the line the lowercase twin wraps.
|
|
155
|
+
const SHOUTED_NOWRAP_MAIL = `${LAYOUT_CLAMP_CSS}
|
|
156
|
+
<div STYLE="WHITE-SPACE: NOWRAP" id="shouted">
|
|
157
|
+
<p>De repetitie van donderdag gaat door. We beginnen met het nieuwe stuk en repeteren om 20.00 uur verder aan het programma voor het najaarsconcert.</p>
|
|
158
|
+
</div>
|
|
159
|
+
`;
|
|
160
|
+
|
|
161
|
+
// A code block with a nowrap span inside it. Unwrapping that span to `normal`
|
|
162
|
+
// collapses the runs of spaces that are the entire point of a `<pre>`, so the
|
|
163
|
+
// clamp has to wrap it instead of flattening it.
|
|
164
|
+
const PREFORMATTED_MAIL = `${LAYOUT_CLAMP_CSS}
|
|
165
|
+
<pre id="listing">outer <span style="white-space:nowrap" id="inner">A B</span></pre>
|
|
166
|
+
`;
|
|
167
|
+
|
|
168
|
+
// The reading pane's ground behind the frame, so a seam between the two shows.
|
|
169
|
+
const PANE: Decorator = (Story) => (
|
|
170
|
+
<div className="overflow-x-auto bg-canvas" style={{ width: 720 }}>
|
|
171
|
+
<Story />
|
|
172
|
+
</div>
|
|
173
|
+
);
|
|
174
|
+
|
|
111
175
|
const PHONE: Decorator = (Story) => (
|
|
112
176
|
<div className="overflow-x-auto" style={{ width: 390 }}>
|
|
113
177
|
<Story />
|
|
114
178
|
</div>
|
|
115
179
|
);
|
|
116
180
|
|
|
181
|
+
// A container on a fractional boundary — a flex reading pane at 720.5px, or any
|
|
182
|
+
// browser zoom off 100%. The DOM rounds every width measurement to a whole
|
|
183
|
+
// pixel, so a frame pinned to its measured content width used to overflow this
|
|
184
|
+
// container by a subpixel and grow a scroll track under mail that fits.
|
|
185
|
+
const FRACTIONAL_COLUMN: Decorator = (Story) => (
|
|
186
|
+
<div
|
|
187
|
+
className="overflow-x-auto"
|
|
188
|
+
style={{ width: 720.5, outline: "1px dashed rgba(120,120,120,0.6)" }}
|
|
189
|
+
>
|
|
190
|
+
<Story />
|
|
191
|
+
</div>
|
|
192
|
+
);
|
|
193
|
+
|
|
117
194
|
const COLUMN: Decorator = (Story) => (
|
|
118
195
|
<div className="overflow-x-auto" style={{ width: 720 }}>
|
|
119
196
|
<Story />
|
|
120
197
|
</div>
|
|
121
198
|
);
|
|
122
199
|
|
|
200
|
+
// What the sanitizer reports for these fixtures. Designed mail brings its own
|
|
201
|
+
// ground and its own container padding, so the frame stands back on both; the
|
|
202
|
+
// bare note brings neither, so the frame supplies the pane's ground and the
|
|
203
|
+
// breathing room inside it.
|
|
204
|
+
const DESIGNED = { background: true, spacing: true };
|
|
205
|
+
const BARE = { background: false, spacing: false };
|
|
206
|
+
|
|
123
207
|
const meta: Meta<typeof IsolatedEmailFrame> = {
|
|
124
208
|
title: "Components/IsolatedEmailFrame",
|
|
125
209
|
component: IsolatedEmailFrame,
|
|
@@ -135,43 +219,68 @@ type Story = StoryObj<typeof IsolatedEmailFrame>;
|
|
|
135
219
|
|
|
136
220
|
/** #727: a 600px fixed-width Node Weekly table at a 390px phone width. The inline
|
|
137
221
|
* `min-width:600px` on the `<td>` beats the clamp so the table can't collapse;
|
|
138
|
-
*
|
|
139
|
-
*
|
|
222
|
+
* it scrolls inside the document instead, and the phone-width box around it
|
|
223
|
+
* stays put. */
|
|
140
224
|
export const NodeWeeklyMobile: Story = {
|
|
141
|
-
args: {
|
|
225
|
+
args: {
|
|
226
|
+
html: NODE_WEEKLY,
|
|
227
|
+
variant: "framed",
|
|
228
|
+
isDark: false,
|
|
229
|
+
declares: DESIGNED,
|
|
230
|
+
},
|
|
142
231
|
decorators: [PHONE],
|
|
143
232
|
};
|
|
144
233
|
|
|
145
234
|
/** The same Node Weekly newsletter on a desktop reading column. */
|
|
146
235
|
export const NodeWeeklyDesktop: Story = {
|
|
147
|
-
args: {
|
|
236
|
+
args: {
|
|
237
|
+
html: NODE_WEEKLY,
|
|
238
|
+
variant: "framed",
|
|
239
|
+
isDark: false,
|
|
240
|
+
declares: DESIGNED,
|
|
241
|
+
},
|
|
148
242
|
decorators: [COLUMN],
|
|
149
243
|
};
|
|
150
244
|
|
|
151
245
|
/** Gaslicht.com-style 600px fixed-width marketing mail at phone width: the hero
|
|
152
|
-
* image
|
|
246
|
+
* image reflows to the frame and the fixed body scrolls inside the document. */
|
|
153
247
|
export const GaslichtMobile: Story = {
|
|
154
|
-
args: {
|
|
248
|
+
args: {
|
|
249
|
+
html: GASLICHT,
|
|
250
|
+
variant: "framed",
|
|
251
|
+
isDark: false,
|
|
252
|
+
declares: DESIGNED,
|
|
253
|
+
},
|
|
155
254
|
decorators: [PHONE],
|
|
156
255
|
};
|
|
157
256
|
|
|
158
257
|
/** Substack-style fluid newsletter on a desktop column: fills the reading
|
|
159
258
|
* width via the framed `max(100%, content)` path. */
|
|
160
259
|
export const SubstackDesktop: Story = {
|
|
161
|
-
args: {
|
|
260
|
+
args: {
|
|
261
|
+
html: SUBSTACK,
|
|
262
|
+
variant: "framed",
|
|
263
|
+
isDark: false,
|
|
264
|
+
declares: DESIGNED,
|
|
265
|
+
},
|
|
162
266
|
decorators: [COLUMN],
|
|
163
267
|
};
|
|
164
268
|
|
|
165
269
|
/** Substack fluid newsletter reflowed to a phone width. */
|
|
166
270
|
export const SubstackMobile: Story = {
|
|
167
|
-
args: {
|
|
271
|
+
args: {
|
|
272
|
+
html: SUBSTACK,
|
|
273
|
+
variant: "framed",
|
|
274
|
+
isDark: false,
|
|
275
|
+
declares: DESIGNED,
|
|
276
|
+
},
|
|
168
277
|
decorators: [PHONE],
|
|
169
278
|
};
|
|
170
279
|
|
|
171
280
|
/** Framed newsletter on the DARK reading pane: smart-inverted to charcoal with
|
|
172
281
|
* the hero re-inverted back to natural color. */
|
|
173
282
|
export const NewsletterDarkPane: Story = {
|
|
174
|
-
args: { html: GASLICHT, variant: "framed", isDark: true },
|
|
283
|
+
args: { html: GASLICHT, variant: "framed", isDark: true, declares: DESIGNED },
|
|
175
284
|
parameters: { theme: "dark" },
|
|
176
285
|
decorators: [COLUMN],
|
|
177
286
|
};
|
|
@@ -179,14 +288,390 @@ export const NewsletterDarkPane: Story = {
|
|
|
179
288
|
/** Plain personal email: UI font-stack + theme-aware colors injected so the
|
|
180
289
|
* black-on-white author text stays readable in either theme. */
|
|
181
290
|
export const PlainEmail: Story = {
|
|
182
|
-
args: { html: PLAIN, variant: "plain", isDark: false },
|
|
291
|
+
args: { html: PLAIN, variant: "plain", isDark: false, declares: BARE },
|
|
183
292
|
decorators: [COLUMN],
|
|
184
293
|
};
|
|
185
294
|
|
|
186
295
|
/** Plain email in dark mode: must be light text on the dark surface, never
|
|
187
296
|
* black-on-dark. */
|
|
188
297
|
export const PlainEmailDark: Story = {
|
|
189
|
-
args: { html: PLAIN, variant: "plain", isDark: true },
|
|
298
|
+
args: { html: PLAIN, variant: "plain", isDark: true, declares: BARE },
|
|
190
299
|
parameters: { theme: "dark" },
|
|
191
300
|
decorators: [COLUMN],
|
|
192
301
|
};
|
|
302
|
+
|
|
303
|
+
/** A short plain email in a fractional-width column: nothing overflows, so no
|
|
304
|
+
* horizontal scrollbar may appear under it. */
|
|
305
|
+
export const FitsFractionalColumn: Story = {
|
|
306
|
+
args: { html: PLAIN, variant: "plain", isDark: false, declares: BARE },
|
|
307
|
+
decorators: [FRACTIONAL_COLUMN],
|
|
308
|
+
};
|
|
309
|
+
|
|
310
|
+
/** A fluid newsletter filling the same fractional column: still no scrollbar. */
|
|
311
|
+
export const SubstackFractionalColumn: Story = {
|
|
312
|
+
args: {
|
|
313
|
+
html: SUBSTACK,
|
|
314
|
+
variant: "framed",
|
|
315
|
+
isDark: false,
|
|
316
|
+
declares: DESIGNED,
|
|
317
|
+
},
|
|
318
|
+
decorators: [FRACTIONAL_COLUMN],
|
|
319
|
+
};
|
|
320
|
+
|
|
321
|
+
/** A 1200px table that genuinely does not fit: it scrolls inside the document,
|
|
322
|
+
* and the column holding the frame never grows a scrollbar of its own. */
|
|
323
|
+
export const WideTableScrollsInPlace: Story = {
|
|
324
|
+
args: {
|
|
325
|
+
html: WIDE_TABLE,
|
|
326
|
+
variant: "framed",
|
|
327
|
+
isDark: false,
|
|
328
|
+
declares: DESIGNED,
|
|
329
|
+
},
|
|
330
|
+
decorators: [COLUMN],
|
|
331
|
+
};
|
|
332
|
+
|
|
333
|
+
/** The same table on a phone: the same in-document scroll, one behaviour at
|
|
334
|
+
* every width. */
|
|
335
|
+
export const WideTableMobile: Story = {
|
|
336
|
+
args: {
|
|
337
|
+
html: WIDE_TABLE,
|
|
338
|
+
variant: "framed",
|
|
339
|
+
isDark: false,
|
|
340
|
+
declares: DESIGNED,
|
|
341
|
+
},
|
|
342
|
+
decorators: [PHONE],
|
|
343
|
+
};
|
|
344
|
+
|
|
345
|
+
/** Mail that brings no ground of its own: the frame's is the pane's, so there
|
|
346
|
+
* is no seam and no inner rectangle, and the injected inset reads as margin
|
|
347
|
+
* rather than a colour change. */
|
|
348
|
+
export const BareMailIsOneSurfaceWithThePane: Story = {
|
|
349
|
+
args: { html: BARE_MAIL, variant: "framed", isDark: false, declares: BARE },
|
|
350
|
+
decorators: [PANE],
|
|
351
|
+
};
|
|
352
|
+
|
|
353
|
+
/** The same on the dark pane, where an app-supplied white canvas used to be
|
|
354
|
+
* inverted into a charcoal slab sitting inside the pane. */
|
|
355
|
+
export const BareMailIsOneSurfaceWithThePaneDark: Story = {
|
|
356
|
+
args: { html: BARE_MAIL, variant: "framed", isDark: true, declares: BARE },
|
|
357
|
+
parameters: { theme: "dark" },
|
|
358
|
+
decorators: [PANE],
|
|
359
|
+
};
|
|
360
|
+
|
|
361
|
+
/** Author `nowrap` on flowing text: wrapped to the frame, never cut. */
|
|
362
|
+
export const NowrapTextStillWraps: Story = {
|
|
363
|
+
args: { html: NOWRAP_MAIL, variant: "framed", isDark: false, declares: BARE },
|
|
364
|
+
decorators: [PANE],
|
|
365
|
+
};
|
|
366
|
+
|
|
367
|
+
// An element of the sanitized mail, once the frame has parsed its srcDoc — the
|
|
368
|
+
// story's first paint does not wait for that.
|
|
369
|
+
const frameElement = async (canvasElement: HTMLElement, id: string) => {
|
|
370
|
+
const iframe = canvasElement.querySelector("iframe");
|
|
371
|
+
if (!iframe) throw new Error("no email frame in the story");
|
|
372
|
+
return await waitFor(() => {
|
|
373
|
+
const element = iframe.contentDocument?.getElementById(id);
|
|
374
|
+
if (!element) throw new Error(`the frame has not rendered #${id} yet`);
|
|
375
|
+
return element;
|
|
376
|
+
});
|
|
377
|
+
};
|
|
378
|
+
|
|
379
|
+
const computedWhiteSpace = (element: Element): string =>
|
|
380
|
+
element.ownerDocument.defaultView?.getComputedStyle(element).whiteSpace ?? "";
|
|
381
|
+
|
|
382
|
+
/** The same nowrap in the case Outlook writes it. An attribute-value match is
|
|
383
|
+
* case-sensitive by default, so this line stayed pinned while its lowercase
|
|
384
|
+
* twin wrapped. */
|
|
385
|
+
export const ShoutedNowrapStillWraps: Story = {
|
|
386
|
+
args: {
|
|
387
|
+
html: SHOUTED_NOWRAP_MAIL,
|
|
388
|
+
variant: "framed",
|
|
389
|
+
isDark: false,
|
|
390
|
+
declares: BARE,
|
|
391
|
+
},
|
|
392
|
+
decorators: [PHONE],
|
|
393
|
+
play: async ({ canvasElement }) => {
|
|
394
|
+
const shouted = await frameElement(canvasElement, "shouted");
|
|
395
|
+
await expect(computedWhiteSpace(shouted)).toBe("normal");
|
|
396
|
+
},
|
|
397
|
+
};
|
|
398
|
+
|
|
399
|
+
/** A `<pre>` keeps its spacing. Unwrapping a nowrap span inside it to `normal`
|
|
400
|
+
* collapses the runs of spaces the block exists to preserve, so the clamp
|
|
401
|
+
* wraps it to `pre-wrap` rather than flattening it. */
|
|
402
|
+
export const PreformattedTextKeepsItsSpacing: Story = {
|
|
403
|
+
args: {
|
|
404
|
+
html: PREFORMATTED_MAIL,
|
|
405
|
+
variant: "framed",
|
|
406
|
+
isDark: false,
|
|
407
|
+
declares: BARE,
|
|
408
|
+
},
|
|
409
|
+
decorators: [PANE],
|
|
410
|
+
play: async ({ canvasElement }) => {
|
|
411
|
+
const inner = await frameElement(canvasElement, "inner");
|
|
412
|
+
const doc = inner.ownerDocument;
|
|
413
|
+
await expect(computedWhiteSpace(inner)).toBe("pre-wrap");
|
|
414
|
+
// Computed style is the rule; the rendered width is the spaces surviving.
|
|
415
|
+
// The twin sits in the same block so it inherits the same monospace font
|
|
416
|
+
// and the only difference between the two is the whitespace handling.
|
|
417
|
+
const collapsed = doc.createElement("span");
|
|
418
|
+
collapsed.style.whiteSpace = "normal";
|
|
419
|
+
collapsed.textContent = inner.textContent;
|
|
420
|
+
inner.parentElement?.appendChild(collapsed);
|
|
421
|
+
await expect(inner.getBoundingClientRect().width).toBeGreaterThan(
|
|
422
|
+
collapsed.getBoundingClientRect().width,
|
|
423
|
+
);
|
|
424
|
+
},
|
|
425
|
+
};
|
|
426
|
+
|
|
427
|
+
/* ------------------------------------------------------------------ */
|
|
428
|
+
/* Overflow belongs to the document, not to the app */
|
|
429
|
+
/* ------------------------------------------------------------------ */
|
|
430
|
+
|
|
431
|
+
// An image with a min-width of its own: the clamp's `max-width:100% !important`
|
|
432
|
+
// caps it, and the un-important `* { min-width: 0 }` loses to the inline style,
|
|
433
|
+
// so the picture stays 1400px however narrow the pane is.
|
|
434
|
+
const OVERSIZED_IMAGE = `${LAYOUT_CLAMP_CSS}
|
|
435
|
+
<div style="font-family: Helvetica, Arial, sans-serif; color:#1a1a1a;">
|
|
436
|
+
<h1 style="font-size:20px;margin:0 0 12px;">Site plan, revision C</h1>
|
|
437
|
+
<img src="${HERO}" alt="Site plan" width="1400" style="min-width:1400px;height:auto;" />
|
|
438
|
+
<p>The lot boundary moved two metres north.</p>
|
|
439
|
+
</div>
|
|
440
|
+
`;
|
|
441
|
+
|
|
442
|
+
// A code listing the author pinned to `white-space: pre`: rewrapping it would
|
|
443
|
+
// change what it says, so it stays as wide as its longest line.
|
|
444
|
+
const PINNED_PRE = `${LAYOUT_CLAMP_CSS}
|
|
445
|
+
<div style="font-family: Helvetica, Arial, sans-serif; color:#1a1a1a;">
|
|
446
|
+
<p>The failing command, verbatim:</p>
|
|
447
|
+
<pre style="white-space:pre">rsync --archive --compress --delete --exclude node_modules --exclude .git ./packages/backend deploy@build-01.internal:/srv/releases/2026-08-11
|
|
448
|
+
rsync error: some files could not be transferred (code 23) at main.c(1338)</pre>
|
|
449
|
+
</div>
|
|
450
|
+
`;
|
|
451
|
+
|
|
452
|
+
// A pane with nothing to catch an overflow: no `overflow-x`, so content that
|
|
453
|
+
// escapes the frame shows up as a scrollbar on the pane or on the page, and the
|
|
454
|
+
// assertions below see it.
|
|
455
|
+
const BARE_PANE: Decorator = (Story) => (
|
|
456
|
+
<div data-pane className="bg-canvas" style={{ width: 720 }}>
|
|
457
|
+
<Story />
|
|
458
|
+
</div>
|
|
459
|
+
);
|
|
460
|
+
|
|
461
|
+
const BARE_PHONE: Decorator = (Story) => (
|
|
462
|
+
<div data-pane className="bg-canvas" style={{ width: 390 }}>
|
|
463
|
+
<Story />
|
|
464
|
+
</div>
|
|
465
|
+
);
|
|
466
|
+
|
|
467
|
+
/**
|
|
468
|
+
* Content that genuinely cannot wrap scrolls where it lives — inside the
|
|
469
|
+
* document — and nothing outside the frame moves sideways for it. The frame is
|
|
470
|
+
* the pane's width and the pane has nothing to scroll, so there is no route for
|
|
471
|
+
* the overflow to reach the page either.
|
|
472
|
+
*/
|
|
473
|
+
const assertScrollsInsideTheDocument = async (canvasElement: HTMLElement) => {
|
|
474
|
+
const iframe = canvasElement.querySelector("iframe");
|
|
475
|
+
if (!iframe) throw new Error("no email frame in the story");
|
|
476
|
+
await waitFor(() => {
|
|
477
|
+
const body = iframe.contentDocument?.body;
|
|
478
|
+
if (!body) throw new Error("the frame has not parsed its document yet");
|
|
479
|
+
if (body.scrollWidth <= body.clientWidth) {
|
|
480
|
+
throw new Error("the document is not holding its own overflow yet");
|
|
481
|
+
}
|
|
482
|
+
});
|
|
483
|
+
const pane = canvasElement.querySelector<HTMLElement>("[data-pane]");
|
|
484
|
+
if (!pane) throw new Error("no pane in the story");
|
|
485
|
+
await expect(iframe.getBoundingClientRect().width).toBeLessThanOrEqual(
|
|
486
|
+
pane.clientWidth,
|
|
487
|
+
);
|
|
488
|
+
await expect(pane.scrollWidth).toBeLessThanOrEqual(pane.clientWidth);
|
|
489
|
+
};
|
|
490
|
+
|
|
491
|
+
/** A 1200px table on a desktop reading column. */
|
|
492
|
+
export const WideTableScrollsInsideTheDocument: Story = {
|
|
493
|
+
args: {
|
|
494
|
+
html: WIDE_TABLE,
|
|
495
|
+
variant: "framed",
|
|
496
|
+
isDark: false,
|
|
497
|
+
declares: DESIGNED,
|
|
498
|
+
},
|
|
499
|
+
decorators: [BARE_PANE],
|
|
500
|
+
play: async ({ canvasElement }) => {
|
|
501
|
+
await assertScrollsInsideTheDocument(canvasElement);
|
|
502
|
+
},
|
|
503
|
+
};
|
|
504
|
+
|
|
505
|
+
/** The same table on a phone. */
|
|
506
|
+
export const WideTableScrollsInsideTheDocumentOnAPhone: Story = {
|
|
507
|
+
args: {
|
|
508
|
+
html: WIDE_TABLE,
|
|
509
|
+
variant: "framed",
|
|
510
|
+
isDark: false,
|
|
511
|
+
declares: DESIGNED,
|
|
512
|
+
},
|
|
513
|
+
decorators: [BARE_PHONE],
|
|
514
|
+
play: async ({ canvasElement }) => {
|
|
515
|
+
await assertScrollsInsideTheDocument(canvasElement);
|
|
516
|
+
},
|
|
517
|
+
};
|
|
518
|
+
|
|
519
|
+
/** A 1400px image on a desktop reading column. */
|
|
520
|
+
export const OversizedImageScrollsInsideTheDocument: Story = {
|
|
521
|
+
args: {
|
|
522
|
+
html: OVERSIZED_IMAGE,
|
|
523
|
+
variant: "framed",
|
|
524
|
+
isDark: false,
|
|
525
|
+
declares: BARE,
|
|
526
|
+
},
|
|
527
|
+
decorators: [BARE_PANE],
|
|
528
|
+
play: async ({ canvasElement }) => {
|
|
529
|
+
await assertScrollsInsideTheDocument(canvasElement);
|
|
530
|
+
},
|
|
531
|
+
};
|
|
532
|
+
|
|
533
|
+
/** The same image on a phone. */
|
|
534
|
+
export const OversizedImageScrollsInsideTheDocumentOnAPhone: Story = {
|
|
535
|
+
args: {
|
|
536
|
+
html: OVERSIZED_IMAGE,
|
|
537
|
+
variant: "framed",
|
|
538
|
+
isDark: false,
|
|
539
|
+
declares: BARE,
|
|
540
|
+
},
|
|
541
|
+
decorators: [BARE_PHONE],
|
|
542
|
+
play: async ({ canvasElement }) => {
|
|
543
|
+
await assertScrollsInsideTheDocument(canvasElement);
|
|
544
|
+
},
|
|
545
|
+
};
|
|
546
|
+
|
|
547
|
+
/** A pinned code listing on a desktop reading column. */
|
|
548
|
+
export const PinnedPreScrollsInsideTheDocument: Story = {
|
|
549
|
+
args: {
|
|
550
|
+
html: PINNED_PRE,
|
|
551
|
+
variant: "framed",
|
|
552
|
+
isDark: false,
|
|
553
|
+
declares: BARE,
|
|
554
|
+
},
|
|
555
|
+
decorators: [BARE_PANE],
|
|
556
|
+
play: async ({ canvasElement }) => {
|
|
557
|
+
await assertScrollsInsideTheDocument(canvasElement);
|
|
558
|
+
},
|
|
559
|
+
};
|
|
560
|
+
|
|
561
|
+
/** The same listing on a phone. */
|
|
562
|
+
export const PinnedPreScrollsInsideTheDocumentOnAPhone: Story = {
|
|
563
|
+
args: {
|
|
564
|
+
html: PINNED_PRE,
|
|
565
|
+
variant: "framed",
|
|
566
|
+
isDark: false,
|
|
567
|
+
declares: BARE,
|
|
568
|
+
},
|
|
569
|
+
decorators: [BARE_PHONE],
|
|
570
|
+
play: async ({ canvasElement }) => {
|
|
571
|
+
await assertScrollsInsideTheDocument(canvasElement);
|
|
572
|
+
},
|
|
573
|
+
};
|
|
574
|
+
|
|
575
|
+
/* ------------------------------------------------------------------ */
|
|
576
|
+
/* The frame is the column, whatever the mail is */
|
|
577
|
+
/* ------------------------------------------------------------------ */
|
|
578
|
+
|
|
579
|
+
/**
|
|
580
|
+
* Two columns of the same width, a two-line note in one and 1200px of table in
|
|
581
|
+
* the other. Both frames are their column, exactly — the width the reader sees
|
|
582
|
+
* comes from the app's layout and nothing about the mail can move it. The old
|
|
583
|
+
* policy measured the content and sized the frame to it, which is what put a
|
|
584
|
+
* scroll track under mail that fitted.
|
|
585
|
+
*/
|
|
586
|
+
const assertBothFramesAreTheirColumn = async (canvasElement: HTMLElement) => {
|
|
587
|
+
const panes = [...canvasElement.querySelectorAll<HTMLElement>("[data-pane]")];
|
|
588
|
+
await expect(panes.length).toBe(2);
|
|
589
|
+
for (const pane of panes) {
|
|
590
|
+
const iframe = pane.querySelector("iframe");
|
|
591
|
+
if (!iframe) throw new Error("no email frame in the pane");
|
|
592
|
+
await waitFor(() => {
|
|
593
|
+
if (!iframe.contentDocument?.body) {
|
|
594
|
+
throw new Error("the frame has not parsed its document yet");
|
|
595
|
+
}
|
|
596
|
+
});
|
|
597
|
+
await expect(
|
|
598
|
+
Math.abs(iframe.getBoundingClientRect().width - pane.clientWidth),
|
|
599
|
+
).toBeLessThanOrEqual(1);
|
|
600
|
+
await expect(pane.scrollWidth).toBeLessThanOrEqual(pane.clientWidth);
|
|
601
|
+
}
|
|
602
|
+
};
|
|
603
|
+
|
|
604
|
+
const twoColumns = (width: number) => (
|
|
605
|
+
<div className="flex flex-col gap-4">
|
|
606
|
+
<div data-pane className="bg-canvas" style={{ width }}>
|
|
607
|
+
<IsolatedEmailFrame
|
|
608
|
+
html={BARE_MAIL}
|
|
609
|
+
variant="framed"
|
|
610
|
+
isDark={false}
|
|
611
|
+
declares={BARE}
|
|
612
|
+
/>
|
|
613
|
+
</div>
|
|
614
|
+
<div data-pane className="bg-canvas" style={{ width }}>
|
|
615
|
+
<IsolatedEmailFrame
|
|
616
|
+
html={WIDE_TABLE}
|
|
617
|
+
variant="framed"
|
|
618
|
+
isDark={false}
|
|
619
|
+
declares={DESIGNED}
|
|
620
|
+
/>
|
|
621
|
+
</div>
|
|
622
|
+
</div>
|
|
623
|
+
);
|
|
624
|
+
|
|
625
|
+
/** A desktop reading column. */
|
|
626
|
+
export const FrameIsTheColumnWhateverTheMail: Story = {
|
|
627
|
+
render: () => twoColumns(720),
|
|
628
|
+
play: async ({ canvasElement }) => {
|
|
629
|
+
await assertBothFramesAreTheirColumn(canvasElement);
|
|
630
|
+
},
|
|
631
|
+
};
|
|
632
|
+
|
|
633
|
+
/** The same pair on a phone, where the table is three times the column. */
|
|
634
|
+
export const FrameIsTheColumnWhateverTheMailOnAPhone: Story = {
|
|
635
|
+
render: () => twoColumns(390),
|
|
636
|
+
play: async ({ canvasElement }) => {
|
|
637
|
+
await assertBothFramesAreTheirColumn(canvasElement);
|
|
638
|
+
},
|
|
639
|
+
};
|
|
640
|
+
|
|
641
|
+
// A pane as wide as the page, with nothing to catch an overflow: an email that
|
|
642
|
+
// escaped its frame has nowhere to go but the document itself, where a
|
|
643
|
+
// horizontal scrollbar under the whole app is what the reader would see.
|
|
644
|
+
const PAGE_WIDE_PANE: Decorator = (Story) => (
|
|
645
|
+
<div data-pane className="w-full bg-canvas">
|
|
646
|
+
<Story />
|
|
647
|
+
</div>
|
|
648
|
+
);
|
|
649
|
+
|
|
650
|
+
/**
|
|
651
|
+
* A 1400px image in a pane the width of the page. The mail scrolls where it
|
|
652
|
+
* lives and the page holds still — the app never learns how wide the picture
|
|
653
|
+
* was.
|
|
654
|
+
*/
|
|
655
|
+
export const OversizedImageLeavesThePageStill: Story = {
|
|
656
|
+
args: {
|
|
657
|
+
html: OVERSIZED_IMAGE,
|
|
658
|
+
variant: "framed",
|
|
659
|
+
isDark: false,
|
|
660
|
+
declares: BARE,
|
|
661
|
+
},
|
|
662
|
+
decorators: [PAGE_WIDE_PANE],
|
|
663
|
+
play: async ({ canvasElement }) => {
|
|
664
|
+
const pane = canvasElement.querySelector<HTMLElement>("[data-pane]");
|
|
665
|
+
if (!pane) throw new Error("no pane in the story");
|
|
666
|
+
const iframe = pane.querySelector("iframe");
|
|
667
|
+
if (!iframe) throw new Error("no email frame in the pane");
|
|
668
|
+
await waitFor(() => {
|
|
669
|
+
if (!iframe.contentDocument?.body) {
|
|
670
|
+
throw new Error("the frame has not parsed its document yet");
|
|
671
|
+
}
|
|
672
|
+
});
|
|
673
|
+
await expect(pane.scrollWidth).toBeLessThanOrEqual(pane.clientWidth);
|
|
674
|
+
const page = canvasElement.ownerDocument.documentElement;
|
|
675
|
+
await expect(page.scrollWidth).toBeLessThanOrEqual(page.clientWidth);
|
|
676
|
+
},
|
|
677
|
+
};
|