@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,6 +1,9 @@
|
|
|
1
1
|
import { useEffect, useMemo, useRef, useState } from "react";
|
|
2
|
-
import {
|
|
3
|
-
|
|
2
|
+
import {
|
|
3
|
+
type AuthorDeclarations,
|
|
4
|
+
buildEmailSrcDoc,
|
|
5
|
+
type EmailFrameVariant,
|
|
6
|
+
} from "./email-frame-css.js";
|
|
4
7
|
|
|
5
8
|
export interface IsolatedEmailFrameProps {
|
|
6
9
|
/**
|
|
@@ -26,6 +29,13 @@ export interface IsolatedEmailFrameProps {
|
|
|
26
29
|
* or apply the smart-invert.
|
|
27
30
|
*/
|
|
28
31
|
isDark?: boolean;
|
|
32
|
+
/**
|
|
33
|
+
* What the mail declares about its own presentation, from the sanitizer. A
|
|
34
|
+
* declared background renders as authored; where the mail declares none the
|
|
35
|
+
* frame's ground is the reading pane's own colour. A mail that declares no
|
|
36
|
+
* padding or margin is given breathing room inside that ground.
|
|
37
|
+
*/
|
|
38
|
+
declares?: AuthorDeclarations;
|
|
29
39
|
className?: string;
|
|
30
40
|
}
|
|
31
41
|
|
|
@@ -34,36 +44,21 @@ export interface IsolatedEmailFrameProps {
|
|
|
34
44
|
// preferable to letting a hostile sender allocate unbounded layout.
|
|
35
45
|
const MAX_HEIGHT_PX = 50_000;
|
|
36
46
|
|
|
37
|
-
// Same spirit as MAX_HEIGHT_PX for the horizontal axis. Fixed-width newsletters
|
|
38
|
-
// top out around 900px; well past that a hostile sender is the likely cause.
|
|
39
|
-
const MAX_WIDTH_PX = 10_000;
|
|
40
|
-
|
|
41
|
-
// Below this we are on a phone: a wide fixed-layout email that cannot reflow is
|
|
42
|
-
// scaled down to fit the container instead of being clipped (#727). Wider
|
|
43
|
-
// viewports keep the content-width pin so multi-column newsletters render at
|
|
44
|
-
// their native width and the pane scrolls horizontally.
|
|
45
|
-
const NARROW_QUERY = "(max-width: 640px)";
|
|
46
|
-
|
|
47
|
-
// Don't scale below this — a heavily fixed-width newsletter on a tiny phone
|
|
48
|
-
// would otherwise shrink to unreadable. At the floor we accept that the email
|
|
49
|
-
// is downscaled as far as we'll go and the wrapper still clips the remainder
|
|
50
|
-
// (text stays larger and legible, edge content is sacrificed over a 3x shrink).
|
|
51
|
-
const MIN_SCALE = 0.4;
|
|
52
|
-
|
|
53
47
|
// sandbox flags: scripts blocked (DOMPurify already strips them; defence in
|
|
54
48
|
// depth), forms blocked, top navigation blocked. `allow-popups` +
|
|
55
49
|
// `allow-popups-to-escape-sandbox` lets `target="_blank"` links open in a new
|
|
56
50
|
// tab. `allow-same-origin` is required so the parent can read
|
|
57
|
-
// `contentDocument.body` to
|
|
51
|
+
// `contentDocument.body` to give the frame its content's height — safe without
|
|
58
52
|
// `allow-scripts` since there is no JS in the frame to exploit it.
|
|
59
53
|
const SANDBOX = "allow-same-origin allow-popups allow-popups-to-escape-sandbox";
|
|
60
54
|
|
|
61
55
|
/**
|
|
62
|
-
* Pin
|
|
63
|
-
* and documentElement scroll sizes, round UP so a fractional content size
|
|
64
|
-
* leaves a 1px phantom overflow, and cap at `max` so a hostile sender
|
|
65
|
-
* allocate unbounded layout. Returned value is the explicit px the iframe
|
|
66
|
-
* sized to
|
|
56
|
+
* Pin the frame's height to its content's scroll size: take the larger of the
|
|
57
|
+
* body and documentElement scroll sizes, round UP so a fractional content size
|
|
58
|
+
* never leaves a 1px phantom overflow, and cap at `max` so a hostile sender
|
|
59
|
+
* can't allocate unbounded layout. Returned value is the explicit px the iframe
|
|
60
|
+
* is sized to. A seamless inline frame has to auto-size vertically; the width is
|
|
61
|
+
* the pane's and is never read off the mail.
|
|
67
62
|
*/
|
|
68
63
|
export const measureContentAxis = (
|
|
69
64
|
bodyScroll: number,
|
|
@@ -71,22 +66,6 @@ export const measureContentAxis = (
|
|
|
71
66
|
max: number,
|
|
72
67
|
): number => Math.min(Math.ceil(Math.max(bodyScroll, rootScroll)), max);
|
|
73
68
|
|
|
74
|
-
/**
|
|
75
|
-
* The fit-to-width scale for a phone: downscale-only, so content already inside
|
|
76
|
-
* the container renders 1:1 and only genuinely-wider content shrinks. Floored at
|
|
77
|
-
* `MIN_SCALE` so a pathologically wide email doesn't shrink to unreadable. A
|
|
78
|
-
* non-positive or unknown width yields `1` (no scale) so we never divide by zero
|
|
79
|
-
* or upscale before the first measurement lands.
|
|
80
|
-
*/
|
|
81
|
-
export const computeFitScale = (
|
|
82
|
-
contentWidth: number,
|
|
83
|
-
containerWidth: number,
|
|
84
|
-
): number => {
|
|
85
|
-
if (contentWidth <= 0 || containerWidth <= 0) return 1;
|
|
86
|
-
if (contentWidth <= containerWidth) return 1;
|
|
87
|
-
return Math.max(MIN_SCALE, containerWidth / contentWidth);
|
|
88
|
-
};
|
|
89
|
-
|
|
90
69
|
/** Named (non-character) keys worth replaying: moving around and closing. */
|
|
91
70
|
const FORWARDED_NAMED_KEYS = new Set([
|
|
92
71
|
"Enter",
|
|
@@ -143,53 +122,37 @@ const forwardKeyDown = (event: KeyboardEvent) => {
|
|
|
143
122
|
};
|
|
144
123
|
|
|
145
124
|
/**
|
|
146
|
-
* Render untrusted (sanitized) email HTML in a sandboxed iframe that
|
|
147
|
-
*
|
|
125
|
+
* Render untrusted (sanitized) email HTML in a sandboxed iframe that is exactly
|
|
126
|
+
* as wide as the pane holding it and isolates the email's CSS from the app
|
|
127
|
+
* chrome.
|
|
148
128
|
*
|
|
149
|
-
* Presentational: HTML + treatment + theme come in via props; the component
|
|
150
|
-
*
|
|
151
|
-
*
|
|
152
|
-
*
|
|
153
|
-
*
|
|
154
|
-
* the
|
|
129
|
+
* Presentational: HTML + treatment + theme come in via props; the component owns
|
|
130
|
+
* the srcDoc assembly and the height. The width is the app's layout and nothing
|
|
131
|
+
* else — the frame is never widened to fit the mail, so no measurement of the
|
|
132
|
+
* email can move a box the reader can see. Content that genuinely cannot wrap (a
|
|
133
|
+
* fixed-width table, an oversized image, a `pre` the author pinned) scrolls
|
|
134
|
+
* inside the document, where it lives; the pane and the page never learn about
|
|
135
|
+
* it.
|
|
155
136
|
*
|
|
156
|
-
*
|
|
157
|
-
*
|
|
158
|
-
*
|
|
159
|
-
* the email stays whole and readable instead of being clipped (#727).
|
|
137
|
+
* Height is the one axis the frame reads off its content: a seamless inline
|
|
138
|
+
* frame has to grow to the mail it shows or it would scroll internally against
|
|
139
|
+
* the page's own scrollbar.
|
|
160
140
|
*/
|
|
161
141
|
export const IsolatedEmailFrame = ({
|
|
162
142
|
html,
|
|
163
143
|
variant = "framed",
|
|
164
144
|
isDark = false,
|
|
145
|
+
declares,
|
|
165
146
|
className,
|
|
166
147
|
}: IsolatedEmailFrameProps) => {
|
|
167
|
-
const hostRef = useRef<HTMLDivElement>(null);
|
|
168
148
|
const ref = useRef<HTMLIFrameElement>(null);
|
|
169
149
|
const [height, setHeight] = useState(0);
|
|
170
|
-
const [width, setWidth] = useState(0);
|
|
171
|
-
const [containerWidth, setContainerWidth] = useState(0);
|
|
172
|
-
|
|
173
|
-
const isNarrow = useMatchMedia(NARROW_QUERY);
|
|
174
150
|
|
|
175
151
|
const srcDoc = useMemo(
|
|
176
|
-
() => buildEmailSrcDoc(html, variant, isDark),
|
|
177
|
-
[html, variant, isDark],
|
|
152
|
+
() => buildEmailSrcDoc(html, variant, isDark, declares),
|
|
153
|
+
[html, variant, isDark, declares],
|
|
178
154
|
);
|
|
179
155
|
|
|
180
|
-
useEffect(() => {
|
|
181
|
-
const host = hostRef.current;
|
|
182
|
-
if (!host) return;
|
|
183
|
-
const measure = () =>
|
|
184
|
-
setContainerWidth((prev) =>
|
|
185
|
-
prev === host.clientWidth ? prev : host.clientWidth,
|
|
186
|
-
);
|
|
187
|
-
measure();
|
|
188
|
-
const observer = new ResizeObserver(measure);
|
|
189
|
-
observer.observe(host);
|
|
190
|
-
return () => observer.disconnect();
|
|
191
|
-
}, []);
|
|
192
|
-
|
|
193
156
|
useEffect(() => {
|
|
194
157
|
const iframe = ref.current;
|
|
195
158
|
if (!iframe) return;
|
|
@@ -198,29 +161,39 @@ export const IsolatedEmailFrame = ({
|
|
|
198
161
|
const doc = iframe.contentDocument;
|
|
199
162
|
if (!doc?.body) return;
|
|
200
163
|
const root = doc.documentElement;
|
|
201
|
-
const
|
|
164
|
+
const next = measureContentAxis(
|
|
202
165
|
doc.body.scrollHeight,
|
|
203
166
|
root?.scrollHeight ?? 0,
|
|
204
167
|
MAX_HEIGHT_PX,
|
|
205
168
|
);
|
|
206
|
-
setHeight((prev) => (prev ===
|
|
207
|
-
const nextWidth = measureContentAxis(
|
|
208
|
-
doc.body.scrollWidth,
|
|
209
|
-
root?.scrollWidth ?? 0,
|
|
210
|
-
MAX_WIDTH_PX,
|
|
211
|
-
);
|
|
212
|
-
setWidth((prev) => (prev === nextWidth ? prev : nextWidth));
|
|
169
|
+
setHeight((prev) => (prev === next ? prev : next));
|
|
213
170
|
};
|
|
214
171
|
|
|
215
172
|
let observer: ResizeObserver | undefined;
|
|
216
173
|
let keyDoc: Document | undefined;
|
|
217
174
|
const handleLoad = () => {
|
|
218
175
|
measure();
|
|
176
|
+
// The srcDoc is rebuilt whenever the mail, theme or treatment changes,
|
|
177
|
+
// so this fires again for a new document; the observer watching the old
|
|
178
|
+
// one has to go with it.
|
|
179
|
+
observer?.disconnect();
|
|
180
|
+
observer = undefined;
|
|
181
|
+
keyDoc?.removeEventListener("keydown", forwardKeyDown);
|
|
182
|
+
keyDoc?.removeEventListener("load", measure, true);
|
|
183
|
+
keyDoc = undefined;
|
|
219
184
|
const doc = iframe.contentDocument;
|
|
220
185
|
if (!doc?.body) return;
|
|
221
186
|
observer = new ResizeObserver(measure);
|
|
222
187
|
observer.observe(doc.body);
|
|
223
188
|
if (doc.documentElement) observer.observe(doc.documentElement);
|
|
189
|
+
// A ResizeObserver watches the body's BOX, which reflows with the pane
|
|
190
|
+
// but not with its own content. Content that arrives late — an image, a
|
|
191
|
+
// webfont that re-flows the text taller — changes the scroll size
|
|
192
|
+
// underneath a box that never moves, so without these the frame keeps a
|
|
193
|
+
// height it took before the mail finished laying out and clips the
|
|
194
|
+
// difference.
|
|
195
|
+
doc.addEventListener("load", measure, true);
|
|
196
|
+
doc.fonts?.ready.then(measure);
|
|
224
197
|
doc.addEventListener("keydown", forwardKeyDown);
|
|
225
198
|
keyDoc = doc;
|
|
226
199
|
};
|
|
@@ -229,51 +202,23 @@ export const IsolatedEmailFrame = ({
|
|
|
229
202
|
return () => {
|
|
230
203
|
iframe.removeEventListener("load", handleLoad);
|
|
231
204
|
keyDoc?.removeEventListener("keydown", forwardKeyDown);
|
|
205
|
+
keyDoc?.removeEventListener("load", measure, true);
|
|
232
206
|
observer?.disconnect();
|
|
233
207
|
};
|
|
234
208
|
}, []);
|
|
235
209
|
|
|
236
|
-
|
|
237
|
-
// - Phone (`isNarrow`): render the iframe at its natural content width and
|
|
238
|
-
// CSS-scale the whole frame down to the container, so a fixed-width
|
|
239
|
-
// newsletter that can't reflow fits the phone whole instead of being
|
|
240
|
-
// clipped (#727). Content already within the container renders 1:1.
|
|
241
|
-
// - Desktop framed: `max(100%, content)` so a narrow-max-width newsletter
|
|
242
|
-
// (Substack's 640px body) fills the reading column while a genuinely wide
|
|
243
|
-
// fixed-layout email grows past the pane and lets the pane scroll.
|
|
244
|
-
// - Plain / pre-measurement: pin to measured content width, 100% until known.
|
|
245
|
-
const scale = isNarrow ? computeFitScale(width, containerWidth) : 1;
|
|
246
|
-
const scaled = scale < 1;
|
|
247
|
-
|
|
248
|
-
const frameWidth = scaled
|
|
249
|
-
? `${width}px`
|
|
250
|
-
: isNarrow
|
|
251
|
-
? "100%"
|
|
252
|
-
: variant === "framed" && width > 0
|
|
253
|
-
? `max(100%, ${width}px)`
|
|
254
|
-
: width === 0
|
|
255
|
-
? "100%"
|
|
256
|
-
: `${width}px`;
|
|
257
|
-
|
|
258
|
-
const frameHeight = height === 0 ? "1px" : `${height}px`;
|
|
259
|
-
|
|
260
|
-
const iframe = (
|
|
210
|
+
return (
|
|
261
211
|
<iframe
|
|
262
212
|
ref={ref}
|
|
263
213
|
title="Email content"
|
|
264
214
|
sandbox={SANDBOX}
|
|
265
215
|
srcDoc={srcDoc}
|
|
266
|
-
className={
|
|
267
|
-
scrolling="no"
|
|
216
|
+
className={className}
|
|
268
217
|
style={{
|
|
269
|
-
width:
|
|
270
|
-
maxWidth: scaled ? "none" : undefined,
|
|
218
|
+
width: "100%",
|
|
271
219
|
border: "none",
|
|
272
220
|
display: "block",
|
|
273
|
-
height:
|
|
274
|
-
overflow: "hidden",
|
|
275
|
-
transform: scaled ? `scale(${scale})` : undefined,
|
|
276
|
-
transformOrigin: scaled ? "top left" : undefined,
|
|
221
|
+
height: height === 0 ? "1px" : `${height}px`,
|
|
277
222
|
// Both branches carry their own color-scheme (and, for the framed
|
|
278
223
|
// dark-invert case, the darkening filter) in the injected base CSS,
|
|
279
224
|
// so the iframe element stays "normal" rather than pinning a scheme
|
|
@@ -282,26 +227,4 @@ export const IsolatedEmailFrame = ({
|
|
|
282
227
|
}}
|
|
283
228
|
/>
|
|
284
229
|
);
|
|
285
|
-
|
|
286
|
-
// When scaled, the iframe's layout box stays its natural (un-transformed)
|
|
287
|
-
// size, so it must sit in a wrapper sized to the SCALED footprint and clip
|
|
288
|
-
// the overflow — otherwise the surrounding pane sees the natural width and
|
|
289
|
-
// grows a scrollbar.
|
|
290
|
-
return (
|
|
291
|
-
<div ref={hostRef} className={scaled ? className : undefined}>
|
|
292
|
-
{scaled ? (
|
|
293
|
-
<div
|
|
294
|
-
style={{
|
|
295
|
-
width: "100%",
|
|
296
|
-
height: `${Math.ceil(height * scale)}px`,
|
|
297
|
-
overflow: "hidden",
|
|
298
|
-
}}
|
|
299
|
-
>
|
|
300
|
-
{iframe}
|
|
301
|
-
</div>
|
|
302
|
-
) : (
|
|
303
|
-
iframe
|
|
304
|
-
)}
|
|
305
|
-
</div>
|
|
306
|
-
);
|
|
307
230
|
};
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { Decorator, Meta, StoryObj } from "@storybook/react-vite";
|
|
2
2
|
import { MessageBodyView } from "./message-body-view.js";
|
|
3
|
+
import { ExpandedMessage } from "./reading-pane.js";
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* `MessageBodyView` is the single source of truth for rendering an email body:
|
|
@@ -7,7 +8,7 @@ import { MessageBodyView } from "./message-body-view.js";
|
|
|
7
8
|
* as framed (designed mail) or plain (theme-aware), and hands the result to the
|
|
8
9
|
* sandboxed `IsolatedEmailFrame`. The app's `MessageBody` and the kit reading
|
|
9
10
|
* panes both compose it, so Storybook renders email exactly as the app does
|
|
10
|
-
* (#940) — sandbox, flush layout and
|
|
11
|
+
* (#940) — sandbox, flush layout and in-document overflow all visible.
|
|
11
12
|
*
|
|
12
13
|
* Unlike the `IsolatedEmailFrame` stories, these fixtures pass RAW author HTML:
|
|
13
14
|
* the component runs the real sanitizer, so the layout-clamp `<style>` and the
|
|
@@ -43,6 +44,44 @@ const PLAIN = `
|
|
|
43
44
|
</div>
|
|
44
45
|
`;
|
|
45
46
|
|
|
47
|
+
// Formatted HTML mail that declares nothing: no background, no padding, no
|
|
48
|
+
// width. The app supplies the ground, so the ground must be the pane's own and
|
|
49
|
+
// the breathing room must sit inside it — one surface with comfortable margins,
|
|
50
|
+
// not a lighter rectangle seamed into the pane.
|
|
51
|
+
const UNSTYLED_HTML = `
|
|
52
|
+
<div>
|
|
53
|
+
<p>Hoi allemaal,</p>
|
|
54
|
+
<p>De repetitie van donderdag gaat door. We beginnen met het nieuwe stuk en
|
|
55
|
+
repeteren om 20.00 uur verder aan het programma voor het najaarsconcert.</p>
|
|
56
|
+
<p><b>Neem je eigen partituur mee</b> — er zijn geen reservekopieën.</p>
|
|
57
|
+
<p>Groeten,<br>Ingrid</p>
|
|
58
|
+
</div>
|
|
59
|
+
`;
|
|
60
|
+
|
|
61
|
+
// The same mail with a nowrap its author never expected a client to honour:
|
|
62
|
+
// flowing text pinned to one line would scroll sideways forever, so the clamp
|
|
63
|
+
// wraps it to the frame instead.
|
|
64
|
+
const UNSTYLED_HTML_NOWRAP = `
|
|
65
|
+
<div style="white-space:nowrap">
|
|
66
|
+
<p>Hoi allemaal,</p>
|
|
67
|
+
<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>
|
|
68
|
+
<p>Groeten,<br>Ingrid</p>
|
|
69
|
+
</div>
|
|
70
|
+
`;
|
|
71
|
+
|
|
72
|
+
// A short plain-text message: no HTML part at all. It is not an email document
|
|
73
|
+
// and has no ground of its own, so it keeps the reading pane's gutter rather
|
|
74
|
+
// than running to the pane edge the way a sandboxed frame does.
|
|
75
|
+
const PLAIN_TEXT = `Hoi allemaal,
|
|
76
|
+
|
|
77
|
+
De repetitie van donderdag gaat door. We beginnen met het nieuwe stuk en
|
|
78
|
+
repeteren om 20.00 uur verder aan het programma voor het najaarsconcert.
|
|
79
|
+
|
|
80
|
+
Neem je eigen partituur mee - er zijn geen reservekopieen.
|
|
81
|
+
|
|
82
|
+
Groeten,
|
|
83
|
+
Ingrid`;
|
|
84
|
+
|
|
46
85
|
// Marketing mail with two remote images — with images blocked the sanitizer
|
|
47
86
|
// swaps them for placeholders and the privacy notice slot reports the count.
|
|
48
87
|
const WITH_REMOTE_IMAGES = `
|
|
@@ -54,18 +93,91 @@ const WITH_REMOTE_IMAGES = `
|
|
|
54
93
|
</div>
|
|
55
94
|
`;
|
|
56
95
|
|
|
96
|
+
// A short personal note: a few lines of text, nowhere near the width of the
|
|
97
|
+
// reading column. Nothing here can overflow, so the pane must show no
|
|
98
|
+
// horizontal scrollbar at any column width.
|
|
99
|
+
const SHORT_NOTE = `
|
|
100
|
+
<div>
|
|
101
|
+
<p>Booked. See you at 3.</p>
|
|
102
|
+
</div>
|
|
103
|
+
`;
|
|
104
|
+
|
|
105
|
+
// Real mail that genuinely does not fit: a report table whose inline min-width
|
|
106
|
+
// beats the sanitizer's clamp (an inline style outranks its `* { min-width: 0 }`),
|
|
107
|
+
// so the table stays 1200px wide however narrow the column is and has to be
|
|
108
|
+
// reachable from inside the frame.
|
|
109
|
+
const WIDE_TABLE = `
|
|
110
|
+
<div style="font-family: Helvetica, Arial, sans-serif; color:#1a1a1a;">
|
|
111
|
+
<h1 style="font-size:20px;margin:0 0 12px;">Q2 regional breakdown</h1>
|
|
112
|
+
<table cellpadding="8" cellspacing="0" style="min-width:1200px;border-collapse:collapse;">
|
|
113
|
+
<tr style="background:#efefef;">
|
|
114
|
+
<th style="min-width:200px;text-align:left;">Region</th>
|
|
115
|
+
<th style="min-width:200px;text-align:left;">Pipeline</th>
|
|
116
|
+
<th style="min-width:200px;text-align:left;">Closed won</th>
|
|
117
|
+
<th style="min-width:200px;text-align:left;">Closed lost</th>
|
|
118
|
+
<th style="min-width:200px;text-align:left;">Forecast</th>
|
|
119
|
+
<th style="min-width:200px;text-align:left;">Owner</th>
|
|
120
|
+
</tr>
|
|
121
|
+
<tr>
|
|
122
|
+
<td>Benelux</td><td>€1.2M</td><td>€480k</td><td>€120k</td><td>€1.6M</td><td>Sanne de Vries</td>
|
|
123
|
+
</tr>
|
|
124
|
+
<tr style="background:#f8f8f8;">
|
|
125
|
+
<td>DACH</td><td>€2.4M</td><td>€910k</td><td>€300k</td><td>€3.1M</td><td>Jonas Brandt</td>
|
|
126
|
+
</tr>
|
|
127
|
+
<tr>
|
|
128
|
+
<td>Nordics</td><td>€780k</td><td>€260k</td><td>€90k</td><td>€1.0M</td><td>Elin Karlsson</td>
|
|
129
|
+
</tr>
|
|
130
|
+
</table>
|
|
131
|
+
<p>Full detail in the attached sheet.</p>
|
|
132
|
+
</div>
|
|
133
|
+
`;
|
|
134
|
+
|
|
57
135
|
const COLUMN: Decorator = (Story) => (
|
|
58
136
|
<div style={{ width: 720 }}>
|
|
59
137
|
<Story />
|
|
60
138
|
</div>
|
|
61
139
|
);
|
|
62
140
|
|
|
141
|
+
// A reading column on a fractional boundary — a flex pane at 720.5px, or any
|
|
142
|
+
// browser zoom off 100%. This is where a frame that took its width from its own
|
|
143
|
+
// content used to overflow its pane by a subpixel and grow a full-width scroll
|
|
144
|
+
// track under mail that plainly fits. The outline marks the column edge.
|
|
145
|
+
const FRACTIONAL_COLUMN: Decorator = (Story) => (
|
|
146
|
+
<div style={{ width: 720.5, outline: "1px dashed rgba(120,120,120,0.6)" }}>
|
|
147
|
+
<Story />
|
|
148
|
+
</div>
|
|
149
|
+
);
|
|
150
|
+
|
|
63
151
|
const PHONE: Decorator = (Story) => (
|
|
64
152
|
<div style={{ width: 390 }}>
|
|
65
153
|
<Story />
|
|
66
154
|
</div>
|
|
67
155
|
);
|
|
68
156
|
|
|
157
|
+
// The message the pane puts the body under. Only the header reads it — the body
|
|
158
|
+
// is the story.
|
|
159
|
+
const PANE_MESSAGE = {
|
|
160
|
+
id: "pane-1",
|
|
161
|
+
fromName: "Ingrid Bakker",
|
|
162
|
+
fromEmail: "ingrid@example.com",
|
|
163
|
+
toLabel: "the choir list",
|
|
164
|
+
dateLabel: "Today, 20:04",
|
|
165
|
+
snippet: "De repetitie van donderdag gaat door.",
|
|
166
|
+
bodyHtml: "",
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
// The reading pane's own arrangement, composed from the component that owns it:
|
|
170
|
+
// the pane's canvas, the message gutter and the sandboxed frame running back out
|
|
171
|
+
// of that gutter. The story states the pane's width and nothing else — the
|
|
172
|
+
// inset, and the cancel that matches it, stay in `ExpandedMessage` where the app
|
|
173
|
+
// reads them from. Any seam between the email's ground and the pane around it
|
|
174
|
+
// shows up here.
|
|
175
|
+
const PANE: Decorator = (Story) => (
|
|
176
|
+
<div className="w-[720px] bg-canvas">
|
|
177
|
+
<ExpandedMessage message={PANE_MESSAGE} body={<Story />} />
|
|
178
|
+
</div>
|
|
179
|
+
);
|
|
180
|
+
|
|
69
181
|
const meta: Meta<typeof MessageBodyView> = {
|
|
70
182
|
title: "Components/MessageBodyView",
|
|
71
183
|
component: MessageBodyView,
|
|
@@ -86,7 +198,8 @@ export const Newsletter: Story = {
|
|
|
86
198
|
decorators: [COLUMN],
|
|
87
199
|
};
|
|
88
200
|
|
|
89
|
-
/** The same newsletter on a phone width —
|
|
201
|
+
/** The same newsletter on a phone width — it reflows to the frame, and what
|
|
202
|
+
* cannot reflow scrolls inside the frame's own document. */
|
|
90
203
|
export const NewsletterMobile: Story = {
|
|
91
204
|
args: { html: NODE_WEEKLY, category: "newsletter", allowImages: true },
|
|
92
205
|
decorators: [PHONE],
|
|
@@ -139,6 +252,91 @@ export const ImagesLoaded: Story = {
|
|
|
139
252
|
decorators: [COLUMN],
|
|
140
253
|
};
|
|
141
254
|
|
|
255
|
+
/** A two-line note in a column whose width is not a whole pixel: the body must
|
|
256
|
+
* show no horizontal scrollbar, because nothing overflows. */
|
|
257
|
+
export const NarrowBodyNoScrollbar: Story = {
|
|
258
|
+
args: { html: SHORT_NOTE, category: "personal", allowImages: true },
|
|
259
|
+
decorators: [FRACTIONAL_COLUMN],
|
|
260
|
+
};
|
|
261
|
+
|
|
262
|
+
/** The same for a fixed-width newsletter that fills the column exactly — it
|
|
263
|
+
* fits, so the pane stays put. */
|
|
264
|
+
export const NewsletterFillsColumnNoScrollbar: Story = {
|
|
265
|
+
args: { html: NODE_WEEKLY, category: "newsletter", allowImages: true },
|
|
266
|
+
decorators: [FRACTIONAL_COLUMN],
|
|
267
|
+
};
|
|
268
|
+
|
|
269
|
+
/** A 1200px table that genuinely cannot fit: it scrolls horizontally inside the
|
|
270
|
+
* frame's own document, and neither the pane nor the page moves sideways. */
|
|
271
|
+
export const WideTableScrollsInPlace: Story = {
|
|
272
|
+
args: { html: WIDE_TABLE, category: "newsletter", allowImages: true },
|
|
273
|
+
decorators: [COLUMN],
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
/** The same wide table on a phone: the same in-document scroll, one behaviour
|
|
277
|
+
* at every width. */
|
|
278
|
+
export const WideTableMobile: Story = {
|
|
279
|
+
args: { html: WIDE_TABLE, category: "newsletter", allowImages: true },
|
|
280
|
+
decorators: [PHONE],
|
|
281
|
+
};
|
|
282
|
+
|
|
283
|
+
/** Formatted HTML mail that declares no background and no padding. The app
|
|
284
|
+
* supplies both: the ground is the pane's own colour, so there is no seam and
|
|
285
|
+
* no inner rectangle, and the breathing room sits inside that ground, so the
|
|
286
|
+
* text is inset while the surface still reaches both pane edges. */
|
|
287
|
+
export const UnstyledHtmlIsOneSurface: Story = {
|
|
288
|
+
args: { html: UNSTYLED_HTML, category: "personal", allowImages: true },
|
|
289
|
+
decorators: [PANE],
|
|
290
|
+
};
|
|
291
|
+
|
|
292
|
+
/** The same mail on the dark pane: the ground is still the pane's, never a
|
|
293
|
+
* lighter slab, and the inset still reads as margin rather than a colour
|
|
294
|
+
* change. */
|
|
295
|
+
export const UnstyledHtmlIsOneSurfaceDark: Story = {
|
|
296
|
+
args: {
|
|
297
|
+
html: UNSTYLED_HTML,
|
|
298
|
+
category: "personal",
|
|
299
|
+
allowImages: true,
|
|
300
|
+
isDark: true,
|
|
301
|
+
},
|
|
302
|
+
parameters: { theme: "dark" },
|
|
303
|
+
decorators: [PANE],
|
|
304
|
+
};
|
|
305
|
+
|
|
306
|
+
/** A newsletter that lays out its own container: it keeps its own background
|
|
307
|
+
* and its own 24px padding, and is given no second helping. */
|
|
308
|
+
export const SelfStyledNewsletterKeepsItsOwnPadding: Story = {
|
|
309
|
+
args: { html: NODE_WEEKLY, category: "newsletter", allowImages: true },
|
|
310
|
+
decorators: [PANE],
|
|
311
|
+
};
|
|
312
|
+
|
|
313
|
+
/** The same newsletter on the dark pane: its design is its own and is darkened
|
|
314
|
+
* as authored, not repainted. */
|
|
315
|
+
export const SelfStyledNewsletterKeepsItsOwnPaddingDark: Story = {
|
|
316
|
+
args: {
|
|
317
|
+
html: NODE_WEEKLY,
|
|
318
|
+
category: "newsletter",
|
|
319
|
+
allowImages: true,
|
|
320
|
+
isDark: true,
|
|
321
|
+
},
|
|
322
|
+
parameters: { theme: "dark" },
|
|
323
|
+
decorators: [PANE],
|
|
324
|
+
};
|
|
325
|
+
|
|
326
|
+
/** A plain-text message. There is no email document to run flush, so it keeps
|
|
327
|
+
* the message gutter and stays readable against the pane edge. */
|
|
328
|
+
export const PlainTextKeepsTheGutter: Story = {
|
|
329
|
+
args: { text: PLAIN_TEXT },
|
|
330
|
+
decorators: [PANE],
|
|
331
|
+
};
|
|
332
|
+
|
|
333
|
+
/** Author `nowrap` on flowing text: the clamp wraps it to the frame instead of
|
|
334
|
+
* pinning a line the frame can only cut. */
|
|
335
|
+
export const NowrapTextStillWraps: Story = {
|
|
336
|
+
args: { html: UNSTYLED_HTML_NOWRAP, category: "personal", allowImages: true },
|
|
337
|
+
decorators: [PANE],
|
|
338
|
+
};
|
|
339
|
+
|
|
142
340
|
/** No body content: the empty-state fallback. */
|
|
143
341
|
export const Empty: Story = {
|
|
144
342
|
args: { html: undefined, text: undefined },
|