@remit/ui 0.0.1
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 +50 -0
- package/src/components/address-display.render.test.ts +93 -0
- package/src/components/address-display.stories.tsx +55 -0
- package/src/components/address-display.tsx +132 -0
- package/src/components/address-tag.render.test.ts +39 -0
- package/src/components/address-tag.stories.tsx +41 -0
- package/src/components/address-tag.tsx +38 -0
- package/src/components/app-password-hint.render.test.ts +23 -0
- package/src/components/app-password-hint.stories.tsx +19 -0
- package/src/components/app-password-hint.tsx +24 -0
- package/src/components/app-shell-slotted.render.test.ts +128 -0
- package/src/components/app-shell-slotted.tsx +267 -0
- package/src/components/app-shell-types.ts +328 -0
- package/src/components/app-shell.render.test.ts +415 -0
- package/src/components/app-shell.tsx +277 -0
- package/src/components/auth-card.render.test.ts +24 -0
- package/src/components/auth-card.stories.tsx +50 -0
- package/src/components/auth-card.tsx +32 -0
- package/src/components/auth-footer.render.test.ts +17 -0
- package/src/components/auth-footer.stories.tsx +23 -0
- package/src/components/auth-footer.tsx +14 -0
- package/src/components/auth-hero.render.test.ts +31 -0
- package/src/components/auth-hero.stories.tsx +19 -0
- package/src/components/auth-hero.tsx +41 -0
- package/src/components/auto-moved-badge.render.test.ts +42 -0
- package/src/components/auto-moved-badge.stories.tsx +48 -0
- package/src/components/auto-moved-badge.tsx +57 -0
- package/src/components/avatar.tsx +60 -0
- package/src/components/badge.tsx +49 -0
- package/src/components/banner.render.test.ts +58 -0
- package/src/components/banner.stories.tsx +92 -0
- package/src/components/banner.tsx +81 -0
- package/src/components/bottom-sheet.render.test.ts +48 -0
- package/src/components/bottom-sheet.stories.tsx +71 -0
- package/src/components/bottom-sheet.tsx +146 -0
- package/src/components/brief-section.render.test.ts +98 -0
- package/src/components/brief-section.stories.tsx +68 -0
- package/src/components/brief-section.tsx +102 -0
- package/src/components/brief-sections.render.test.ts +72 -0
- package/src/components/brief-sections.stories.tsx +178 -0
- package/src/components/brief-sections.tsx +201 -0
- package/src/components/button.tsx +48 -0
- package/src/components/card.tsx +48 -0
- package/src/components/category-badge.render.test.ts +40 -0
- package/src/components/category-badge.stories.tsx +37 -0
- package/src/components/category-badge.tsx +71 -0
- package/src/components/checkbox.render.test.ts +65 -0
- package/src/components/checkbox.stories.tsx +77 -0
- package/src/components/checkbox.tsx +78 -0
- package/src/components/compose-action-bar.render.test.ts +47 -0
- package/src/components/compose-action-bar.stories.tsx +35 -0
- package/src/components/compose-action-bar.tsx +95 -0
- package/src/components/compose-form-shell.render.test.ts +54 -0
- package/src/components/compose-form-shell.stories.tsx +96 -0
- package/src/components/compose-form-shell.tsx +49 -0
- package/src/components/danger-zone-section.tsx +36 -0
- package/src/components/dialog.render.test.ts +55 -0
- package/src/components/dialog.tsx +104 -0
- package/src/components/email-frame-css.ts +137 -0
- package/src/components/field-label.render.test.ts +16 -0
- package/src/components/field-label.tsx +19 -0
- package/src/components/filter-sheet.render.test.ts +76 -0
- package/src/components/filter-sheet.stories.tsx +146 -0
- package/src/components/filter-sheet.tsx +316 -0
- package/src/components/folder-role.tsx +66 -0
- package/src/components/input.tsx +44 -0
- package/src/components/intelligence-panel.stories.tsx +149 -0
- package/src/components/intelligence-panel.tsx +546 -0
- package/src/components/isolated-email-frame.render.test.ts +147 -0
- package/src/components/isolated-email-frame.stories.tsx +192 -0
- package/src/components/isolated-email-frame.tsx +265 -0
- package/src/components/kbd.tsx +17 -0
- package/src/components/keyboard-hint-bar.tsx +58 -0
- package/src/components/list-item.tsx +39 -0
- package/src/components/mail-action-toolbar.render.test.ts +43 -0
- package/src/components/mail-action-toolbar.tsx +180 -0
- package/src/components/mail-header.render.test.ts +69 -0
- package/src/components/mail-header.stories.tsx +233 -0
- package/src/components/mail-header.tsx +123 -0
- package/src/components/message-body-view.render.test.ts +59 -0
- package/src/components/message-body-view.stories.tsx +146 -0
- package/src/components/message-body-view.tsx +150 -0
- package/src/components/message-header.render.test.ts +58 -0
- package/src/components/message-header.stories.tsx +79 -0
- package/src/components/message-header.tsx +59 -0
- package/src/components/message-list-pane.render.test.ts +136 -0
- package/src/components/message-list-pane.stories.tsx +246 -0
- package/src/components/message-list-pane.tsx +248 -0
- package/src/components/message-list-state.render.test.ts +72 -0
- package/src/components/message-list-state.tsx +97 -0
- package/src/components/message-row.render.test.ts +48 -0
- package/src/components/message-row.stories.tsx +121 -0
- package/src/components/message-row.tsx +231 -0
- package/src/components/mobile-message-action-bar.render.test.ts +87 -0
- package/src/components/mobile-message-action-bar.stories.tsx +57 -0
- package/src/components/mobile-message-action-bar.tsx +175 -0
- package/src/components/mobile-reading-pane.render.test.ts +102 -0
- package/src/components/mobile-reading-pane.tsx +188 -0
- package/src/components/mobile-search-view.stories.tsx +247 -0
- package/src/components/mobile-search-view.tsx +100 -0
- package/src/components/move-mailbox-picker.render.test.ts +113 -0
- package/src/components/move-mailbox-picker.stories.tsx +76 -0
- package/src/components/move-mailbox-picker.tsx +324 -0
- package/src/components/nav-sidebar.render.test.ts +351 -0
- package/src/components/nav-sidebar.stories.tsx +216 -0
- package/src/components/nav-sidebar.tsx +625 -0
- package/src/components/outbox-row.render.test.ts +61 -0
- package/src/components/outbox-row.stories.tsx +53 -0
- package/src/components/outbox-row.tsx +120 -0
- package/src/components/outbox-status-badge.tsx +60 -0
- package/src/components/pane-layout.render.test.ts +105 -0
- package/src/components/popover-menu.render.test.ts +32 -0
- package/src/components/popover-menu.stories.tsx +57 -0
- package/src/components/popover-menu.tsx +110 -0
- package/src/components/primitives.stories.tsx +173 -0
- package/src/components/pull-to-refresh.render.test.ts +46 -0
- package/src/components/pull-to-refresh.stories.tsx +71 -0
- package/src/components/pull-to-refresh.tsx +54 -0
- package/src/components/quoted-text.render.test.ts +39 -0
- package/src/components/quoted-text.stories.tsx +67 -0
- package/src/components/quoted-text.tsx +67 -0
- package/src/components/reading-pane-empty.tsx +44 -0
- package/src/components/reading-pane.render.test.ts +148 -0
- package/src/components/reading-pane.stories.tsx +194 -0
- package/src/components/reading-pane.tsx +351 -0
- package/src/components/rescue-banner.render.test.ts +32 -0
- package/src/components/rescue-banner.stories.tsx +31 -0
- package/src/components/rescue-banner.tsx +50 -0
- package/src/components/rescue-candidate-row.render.test.ts +68 -0
- package/src/components/rescue-candidate-row.stories.tsx +108 -0
- package/src/components/rescue-candidate-row.tsx +94 -0
- package/src/components/rescue-from-spam-flow.render.test.ts +100 -0
- package/src/components/rescue-from-spam-flow.stories.tsx +92 -0
- package/src/components/rescue-from-spam-flow.tsx +342 -0
- package/src/components/resizable.tsx +55 -0
- package/src/components/role-appointment-list.render.test.ts +97 -0
- package/src/components/role-appointment-list.stories.tsx +138 -0
- package/src/components/role-appointment-list.tsx +227 -0
- package/src/components/row-actions.render.test.ts +83 -0
- package/src/components/row-actions.stories.tsx +87 -0
- package/src/components/row-actions.tsx +139 -0
- package/src/components/search-bar.render.test.ts +55 -0
- package/src/components/search-bar.stories.tsx +46 -0
- package/src/components/search-bar.tsx +118 -0
- package/src/components/search-result-row.tsx +139 -0
- package/src/components/search-results.render.test.ts +89 -0
- package/src/components/search-results.stories.tsx +126 -0
- package/src/components/search-results.tsx +215 -0
- package/src/components/search-token-chip.render.test.ts +42 -0
- package/src/components/search-token-chip.stories.tsx +42 -0
- package/src/components/search-token-chip.tsx +66 -0
- package/src/components/security-select.render.test.ts +33 -0
- package/src/components/security-select.tsx +53 -0
- package/src/components/segmented-control.tsx +70 -0
- package/src/components/select.tsx +33 -0
- package/src/components/selection-top-bar.render.test.ts +67 -0
- package/src/components/selection-top-bar.stories.tsx +39 -0
- package/src/components/selection-top-bar.tsx +95 -0
- package/src/components/sender-group-switch.tsx +63 -0
- package/src/components/sender-trust-indicator.render.test.ts +67 -0
- package/src/components/sender-trust-indicator.stories.tsx +58 -0
- package/src/components/sender-trust-indicator.tsx +87 -0
- package/src/components/settings-primitives.render.test.ts +160 -0
- package/src/components/settings-primitives.stories.tsx +80 -0
- package/src/components/settings-screen.tsx +354 -0
- package/src/components/swipeable-row.render.test.ts +106 -0
- package/src/components/swipeable-row.stories.tsx +117 -0
- package/src/components/swipeable-row.tsx +268 -0
- package/src/components/touch-list-body.render.test.ts +66 -0
- package/src/components/touch-list-body.stories.tsx +76 -0
- package/src/components/touch-list.tsx +87 -0
- package/src/components/wizard.render.test.ts +49 -0
- package/src/components/wizard.stories.tsx +113 -0
- package/src/components/wizard.tsx +321 -0
- package/src/filter-presets.test.ts +93 -0
- package/src/filter-presets.ts +101 -0
- package/src/index.ts +378 -0
- package/src/lib/cid-resolver.ts +35 -0
- package/src/lib/cn.ts +7 -0
- package/src/lib/email-layout-clamp.test.ts +109 -0
- package/src/lib/email-layout-clamp.ts +64 -0
- package/src/lib/email-render-treatment.test.ts +82 -0
- package/src/lib/email-render-treatment.ts +53 -0
- package/src/lib/email-sanitizer.test.ts +362 -0
- package/src/lib/email-sanitizer.ts +298 -0
- package/src/lib/row-keyboard.test.ts +41 -0
- package/src/lib/row-keyboard.ts +20 -0
- package/src/tokens.css +217 -0
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { type ReactNode, useCallback, useEffect, useRef } from "react";
|
|
2
|
+
import { cn } from "../lib/cn.js";
|
|
3
|
+
|
|
4
|
+
export interface DialogProps {
|
|
5
|
+
open: boolean;
|
|
6
|
+
onClose: () => void;
|
|
7
|
+
title: string;
|
|
8
|
+
/** Unique ID for aria-labelledby. Defaults to "dialog-title". */
|
|
9
|
+
titleId?: string;
|
|
10
|
+
children?: ReactNode;
|
|
11
|
+
className?: string;
|
|
12
|
+
/**
|
|
13
|
+
* Anchor. `"center"` (default) is the centered modal card. `"left"`/`"right"`
|
|
14
|
+
* are full-height slide-over panels pinned to that edge — same backdrop,
|
|
15
|
+
* escape and click-away dismissal. `"left"` is the nav drawer at narrow
|
|
16
|
+
* widths; `"right"` is the mobile intelligence drawer (#854).
|
|
17
|
+
*/
|
|
18
|
+
anchor?: "center" | "left" | "right";
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function Dialog({
|
|
22
|
+
open,
|
|
23
|
+
onClose,
|
|
24
|
+
title,
|
|
25
|
+
titleId = "dialog-title",
|
|
26
|
+
children,
|
|
27
|
+
className,
|
|
28
|
+
anchor = "center",
|
|
29
|
+
}: DialogProps) {
|
|
30
|
+
const dialogRef = useRef<HTMLDivElement>(null);
|
|
31
|
+
|
|
32
|
+
const handleKeyDown = useCallback(
|
|
33
|
+
(e: KeyboardEvent) => {
|
|
34
|
+
if (e.key === "Escape") {
|
|
35
|
+
e.preventDefault();
|
|
36
|
+
e.stopImmediatePropagation();
|
|
37
|
+
onClose();
|
|
38
|
+
}
|
|
39
|
+
},
|
|
40
|
+
[onClose],
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
useEffect(() => {
|
|
44
|
+
if (!open) return;
|
|
45
|
+
window.addEventListener("keydown", handleKeyDown, true);
|
|
46
|
+
return () => window.removeEventListener("keydown", handleKeyDown, true);
|
|
47
|
+
}, [open, handleKeyDown]);
|
|
48
|
+
|
|
49
|
+
useEffect(() => {
|
|
50
|
+
if (!open) return;
|
|
51
|
+
const dialog = dialogRef.current;
|
|
52
|
+
if (!dialog) return;
|
|
53
|
+
const focusable = dialog.querySelectorAll<HTMLElement>(
|
|
54
|
+
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
|
|
55
|
+
);
|
|
56
|
+
focusable[0]?.focus();
|
|
57
|
+
}, [open]);
|
|
58
|
+
|
|
59
|
+
if (!open) return null;
|
|
60
|
+
|
|
61
|
+
const isLeft = anchor === "left";
|
|
62
|
+
const isRight = anchor === "right";
|
|
63
|
+
const isSlideOver = isLeft || isRight;
|
|
64
|
+
|
|
65
|
+
return (
|
|
66
|
+
// biome-ignore lint/a11y/noStaticElementInteractions: outer overlay closes dialog on click; role=presentation lets inner role=dialog own the AT semantics
|
|
67
|
+
<div
|
|
68
|
+
className={cn(
|
|
69
|
+
"fixed inset-0 z-50 flex",
|
|
70
|
+
isLeft
|
|
71
|
+
? "items-stretch justify-start"
|
|
72
|
+
: isRight
|
|
73
|
+
? "items-stretch justify-end"
|
|
74
|
+
: "items-center justify-center px-4",
|
|
75
|
+
)}
|
|
76
|
+
role="presentation"
|
|
77
|
+
onClick={onClose}
|
|
78
|
+
>
|
|
79
|
+
<div className={cn("absolute inset-0", !isSlideOver && "bg-canvas/80")} />
|
|
80
|
+
<div
|
|
81
|
+
ref={dialogRef}
|
|
82
|
+
role="dialog"
|
|
83
|
+
aria-modal="true"
|
|
84
|
+
aria-labelledby={titleId}
|
|
85
|
+
className={cn(
|
|
86
|
+
"relative z-10 overflow-hidden border-line bg-surface shadow-xl",
|
|
87
|
+
isLeft
|
|
88
|
+
? "h-full w-72 max-w-[85vw] border-r"
|
|
89
|
+
: isRight
|
|
90
|
+
? "h-full w-[80vw] max-w-[320px] border-l"
|
|
91
|
+
: "w-full max-w-lg rounded-md border",
|
|
92
|
+
className,
|
|
93
|
+
)}
|
|
94
|
+
onClick={(e) => e.stopPropagation()}
|
|
95
|
+
onKeyDown={(e) => e.stopPropagation()}
|
|
96
|
+
>
|
|
97
|
+
<h2 id={titleId} className="sr-only">
|
|
98
|
+
{title}
|
|
99
|
+
</h2>
|
|
100
|
+
{children}
|
|
101
|
+
</div>
|
|
102
|
+
</div>
|
|
103
|
+
);
|
|
104
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Base CSS injected into the isolated email iframe, by treatment.
|
|
3
|
+
*
|
|
4
|
+
* `IsolatedEmailFrame` owns the full srcDoc, so the "make an untrusted email
|
|
5
|
+
* readable + theme-aware" decision lives here in one place rather than spread
|
|
6
|
+
* across the call site. The sanitizer still prepends its own layout-clamp block
|
|
7
|
+
* (width/wrap rules) ahead of the email body; this module supplies the colour /
|
|
8
|
+
* font / dark-mode canvas that sits in front of it.
|
|
9
|
+
*
|
|
10
|
+
* Two treatments:
|
|
11
|
+
*
|
|
12
|
+
* - `plain` — weakly-marked / personal mail with no author background. Gets the
|
|
13
|
+
* UI sans-serif + theme-aware colours so black-text-on-dark is readable.
|
|
14
|
+
* - `framed` — designed mail (newsletter / marketing / author background). Its
|
|
15
|
+
* own colours are preserved; in dark mode it is darkened via a K-9-style
|
|
16
|
+
* smart-invert unless it opts into its own dark design.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const FONT_STACK =
|
|
20
|
+
'"Geist Variable", "Geist", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif';
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Resolved theme tokens, inlined because CSS custom properties do not cross the
|
|
24
|
+
* iframe boundary. Keep in sync with `tokens.css`; the unit test pins these
|
|
25
|
+
* resolved values so token drift fails loudly.
|
|
26
|
+
*
|
|
27
|
+
* --fg light: oklch(0.3 0.025 235) dark: oklch(0.88 0.02 90)
|
|
28
|
+
* --surface light: oklch(0.975 0.012 90) dark: oklch(0.25 0.025 220)
|
|
29
|
+
* --accent light: oklch(0.55 0.14 150) dark: oklch(0.78 0.16 150)
|
|
30
|
+
*/
|
|
31
|
+
const PLAIN_TOKENS = {
|
|
32
|
+
light: {
|
|
33
|
+
fg: "oklch(0.3 0.025 235)",
|
|
34
|
+
surface: "oklch(0.975 0.012 90)",
|
|
35
|
+
accent: "oklch(0.55 0.14 150)",
|
|
36
|
+
},
|
|
37
|
+
dark: {
|
|
38
|
+
fg: "oklch(0.88 0.02 90)",
|
|
39
|
+
surface: "oklch(0.25 0.025 220)",
|
|
40
|
+
accent: "oklch(0.78 0.16 150)",
|
|
41
|
+
},
|
|
42
|
+
} as const;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Detects whether a (sanitized) email opts into dark rendering — either via a
|
|
46
|
+
* `prefers-color-scheme: dark` media query or an explicit `color-scheme: dark`
|
|
47
|
+
* declaration. Whitespace inside the value is tolerated. When present we honour
|
|
48
|
+
* the author's intent instead of forcing white or smart-inverting.
|
|
49
|
+
*/
|
|
50
|
+
export const DARK_OPT_IN_RE =
|
|
51
|
+
/prefers-color-scheme\s*:\s*dark|color-scheme\s*:\s*[^;}"']*\bdark\b/i;
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Pin the iframe's layout viewport to its own width so author `width=device-
|
|
55
|
+
* width` assumptions resolve to the frame, and a wide fixed-layout email lays
|
|
56
|
+
* out against the frame instead of a desktop-default 980px viewport that would
|
|
57
|
+
* zoom the whole document out on a phone (#727).
|
|
58
|
+
*/
|
|
59
|
+
export const VIEWPORT_META =
|
|
60
|
+
'<meta name="viewport" content="width=device-width, initial-scale=1">';
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Plain-email base CSS: UI font-stack + theme-aware colours. Strips author
|
|
64
|
+
* unreadable text colours (`color:#000` on dark) and element backgrounds while
|
|
65
|
+
* letting font-size / weight variations through, and re-themes links.
|
|
66
|
+
*/
|
|
67
|
+
export const generatePlainEmailBaseCSS = (isDark: boolean): string => {
|
|
68
|
+
const t = isDark ? PLAIN_TOKENS.dark : PLAIN_TOKENS.light;
|
|
69
|
+
return `
|
|
70
|
+
/* Plain-email base: UI font-stack + theme-aware colors (#424) */
|
|
71
|
+
html, body {
|
|
72
|
+
font-family: ${FONT_STACK};
|
|
73
|
+
font-size: 14px;
|
|
74
|
+
line-height: 1.6;
|
|
75
|
+
color: ${t.fg};
|
|
76
|
+
background-color: ${t.surface};
|
|
77
|
+
margin: 0;
|
|
78
|
+
padding: 0;
|
|
79
|
+
}
|
|
80
|
+
/* Strip author unreadable text colors and element backgrounds, scoped to body
|
|
81
|
+
descendants so the themed html/body surface above survives this reset. */
|
|
82
|
+
body * {
|
|
83
|
+
color: inherit !important;
|
|
84
|
+
background-color: transparent !important;
|
|
85
|
+
}
|
|
86
|
+
a, a:visited {
|
|
87
|
+
color: ${t.accent} !important;
|
|
88
|
+
text-decoration: underline;
|
|
89
|
+
}
|
|
90
|
+
`;
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Framed-email base CSS, mirroring K-9 Mail's dark-reading strategy. Three
|
|
95
|
+
* cases, driven by app theme + the email's own dark opt-in:
|
|
96
|
+
*
|
|
97
|
+
* - App light theme → render as authored on a white canvas (light scheme).
|
|
98
|
+
* - App dark theme + email opts into its own dark → render as authored.
|
|
99
|
+
* - App dark theme, no opt-in → CSS smart-invert to darken the email into the
|
|
100
|
+
* dark pane: soft charcoal background, light-grey text.
|
|
101
|
+
*
|
|
102
|
+
* `invert(0.92)` (not 1.0) lands on soft charcoal + light-grey like K-9 rather
|
|
103
|
+
* than pure black/white; the white `html` background gives the invert a light
|
|
104
|
+
* source; `hue-rotate(180deg)` keeps blues blue (links); the media-element rule
|
|
105
|
+
* re-inverts images / logos / photos back to their natural colours. No
|
|
106
|
+
* `!important` — this is a default canvas, so the email's own background still
|
|
107
|
+
* composes. The margin reset zeroes the UA body margin.
|
|
108
|
+
*/
|
|
109
|
+
export const generateFramedEmailBaseCSS = (
|
|
110
|
+
isDark: boolean,
|
|
111
|
+
optsIntoDark: boolean,
|
|
112
|
+
): string => {
|
|
113
|
+
if (!isDark)
|
|
114
|
+
return "html,body{margin:0;background-color:#ffffff;color-scheme:light}";
|
|
115
|
+
if (optsIntoDark) return "html,body{margin:0;color-scheme:dark light}";
|
|
116
|
+
return "html{margin:0;background-color:#ffffff;filter:invert(0.92) hue-rotate(180deg)}body{margin:0}img,picture,video,svg,canvas,[style*='background-image'],[background]{filter:invert(0.92) hue-rotate(180deg)}";
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
export type EmailFrameVariant = "plain" | "framed";
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Assemble the full srcDoc for an isolated email frame: the viewport meta, the
|
|
123
|
+
* treatment's base CSS, then the (already sanitized, layout-clamped) email
|
|
124
|
+
* HTML. This is the single place the colour / font / dark-mode decision is
|
|
125
|
+
* applied — callers pass treatment + theme, never raw CSS.
|
|
126
|
+
*/
|
|
127
|
+
export const buildEmailSrcDoc = (
|
|
128
|
+
html: string,
|
|
129
|
+
variant: EmailFrameVariant,
|
|
130
|
+
isDark: boolean,
|
|
131
|
+
): string => {
|
|
132
|
+
if (variant === "plain") {
|
|
133
|
+
return `${VIEWPORT_META}<style>${generatePlainEmailBaseCSS(isDark)}</style>${html}`;
|
|
134
|
+
}
|
|
135
|
+
const optsIntoDark = DARK_OPT_IN_RE.test(html);
|
|
136
|
+
return `${VIEWPORT_META}<style>${generateFramedEmailBaseCSS(isDark, optsIntoDark)}</style>${html}`;
|
|
137
|
+
};
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import { createElement } from "react";
|
|
4
|
+
import { renderToString } from "react-dom/server";
|
|
5
|
+
import { FieldLabel } from "./field-label.js";
|
|
6
|
+
|
|
7
|
+
describe("FieldLabel", () => {
|
|
8
|
+
it("renders a real label element wired to a control via htmlFor", () => {
|
|
9
|
+
const html = renderToString(
|
|
10
|
+
createElement(FieldLabel, { htmlFor: "host" }, "Host"),
|
|
11
|
+
);
|
|
12
|
+
assert.match(html, /<label/);
|
|
13
|
+
assert.match(html, /for="host"/);
|
|
14
|
+
assert.match(html, />Host</);
|
|
15
|
+
});
|
|
16
|
+
});
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { LabelHTMLAttributes } from "react";
|
|
2
|
+
import { cn } from "../lib/cn.js";
|
|
3
|
+
|
|
4
|
+
export interface FieldLabelProps extends LabelHTMLAttributes<HTMLLabelElement> {
|
|
5
|
+
/** Associates the label with its control via the control's id. */
|
|
6
|
+
htmlFor: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function FieldLabel({ className, children, ...props }: FieldLabelProps) {
|
|
10
|
+
return (
|
|
11
|
+
// biome-ignore lint/a11y/noLabelWithoutControl: htmlFor is always provided via the required props spread
|
|
12
|
+
<label
|
|
13
|
+
className={cn("mb-1 block text-xs font-medium text-fg-muted", className)}
|
|
14
|
+
{...props}
|
|
15
|
+
>
|
|
16
|
+
{children}
|
|
17
|
+
</label>
|
|
18
|
+
);
|
|
19
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import { createElement } from "react";
|
|
4
|
+
import { renderToString } from "react-dom/server";
|
|
5
|
+
import { FilterSheet, type FilterSheetProps } from "./filter-sheet.js";
|
|
6
|
+
|
|
7
|
+
const categories = [
|
|
8
|
+
{ id: "all", label: "All", tone: "neutral" as const },
|
|
9
|
+
{ id: "personal", label: "Personal", tone: "positive" as const },
|
|
10
|
+
];
|
|
11
|
+
|
|
12
|
+
const filters = [
|
|
13
|
+
{ id: "unread", label: "Unread" },
|
|
14
|
+
{ id: "today", label: "Today" },
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
const sources = [
|
|
18
|
+
{ id: "all", label: "All", active: true },
|
|
19
|
+
{ id: "work", label: "work@acme.com", count: 12 },
|
|
20
|
+
];
|
|
21
|
+
|
|
22
|
+
function render(overrides: Partial<FilterSheetProps> = {}): string {
|
|
23
|
+
return renderToString(
|
|
24
|
+
createElement(FilterSheet, {
|
|
25
|
+
categories,
|
|
26
|
+
filters,
|
|
27
|
+
sources,
|
|
28
|
+
selectedCategory: "all",
|
|
29
|
+
activeFilters: new Set<string>(),
|
|
30
|
+
onSelectCategory: () => undefined,
|
|
31
|
+
onSelectSource: () => undefined,
|
|
32
|
+
onToggleFilter: () => undefined,
|
|
33
|
+
onClear: () => undefined,
|
|
34
|
+
onExpandedChange: () => undefined,
|
|
35
|
+
...overrides,
|
|
36
|
+
}),
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
describe("FilterSheet", () => {
|
|
41
|
+
it("collapsed shows the Filters bar and an expand caret", () => {
|
|
42
|
+
const html = render({ expanded: false });
|
|
43
|
+
assert.match(html, /Filters/);
|
|
44
|
+
assert.match(html, /aria-label="Expand filters"/);
|
|
45
|
+
assert.match(html, /aria-expanded="false"/);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("collapsed does not render the filter rows", () => {
|
|
49
|
+
const html = render({ expanded: false });
|
|
50
|
+
assert.doesNotMatch(html, /work@acme\.com/);
|
|
51
|
+
assert.doesNotMatch(html, /Today/);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("expanded renders category, filter, and source rows", () => {
|
|
55
|
+
const html = render({ expanded: true });
|
|
56
|
+
assert.match(html, /aria-label="Collapse filters"/);
|
|
57
|
+
assert.match(html, /Personal/);
|
|
58
|
+
assert.match(html, /Today/);
|
|
59
|
+
assert.match(html, /work@acme\.com/);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("renders a clear control when a filter is active", () => {
|
|
63
|
+
const html = render({
|
|
64
|
+
expanded: true,
|
|
65
|
+
activeFilters: new Set(["unread"]),
|
|
66
|
+
});
|
|
67
|
+
assert.match(html, /aria-label="Clear filters"/);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("is flat: no overlay, scrim, shadow, or rounded-corner chrome", () => {
|
|
71
|
+
const html = render({ expanded: true });
|
|
72
|
+
assert.doesNotMatch(html, /shadow-/);
|
|
73
|
+
assert.doesNotMatch(html, /rounded-b-2xl/);
|
|
74
|
+
assert.doesNotMatch(html, /role="slider"/);
|
|
75
|
+
});
|
|
76
|
+
});
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
|
+
import { useState } from "react";
|
|
3
|
+
import {
|
|
4
|
+
FilterSheet,
|
|
5
|
+
type FilterSheetCategory,
|
|
6
|
+
type FilterSheetFilter,
|
|
7
|
+
type FilterSheetSource,
|
|
8
|
+
} from "./filter-sheet.js";
|
|
9
|
+
|
|
10
|
+
const CATEGORIES: FilterSheetCategory[] = [
|
|
11
|
+
{ id: "all", label: "All", tone: "neutral" },
|
|
12
|
+
{ id: "personal", label: "Personal", tone: "positive" },
|
|
13
|
+
{ id: "newsletters", label: "Newsletters", tone: "accent" },
|
|
14
|
+
{ id: "marketing", label: "Marketing", tone: "warning" },
|
|
15
|
+
{ id: "automated", label: "Automated", tone: "neutral" },
|
|
16
|
+
{ id: "transactional", label: "Transactional", tone: "danger" },
|
|
17
|
+
];
|
|
18
|
+
|
|
19
|
+
const FILTERS: FilterSheetFilter[] = [
|
|
20
|
+
{ id: "unread", label: "Unread" },
|
|
21
|
+
{ id: "attachment", label: "Has attachment" },
|
|
22
|
+
{ id: "contacts", label: "From contacts" },
|
|
23
|
+
{ id: "today", label: "Today" },
|
|
24
|
+
];
|
|
25
|
+
|
|
26
|
+
const SOURCES: FilterSheetSource[] = [
|
|
27
|
+
{ id: "all", label: "All", active: true },
|
|
28
|
+
{ id: "work", label: "work@acme.com", count: 12 },
|
|
29
|
+
{ id: "personal", label: "me@home.net", count: 3 },
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
const meta: Meta<typeof FilterSheet> = {
|
|
33
|
+
title: "FilterSheet",
|
|
34
|
+
component: FilterSheet,
|
|
35
|
+
parameters: {
|
|
36
|
+
layout: "fullscreen",
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
export default meta;
|
|
40
|
+
|
|
41
|
+
type Story = StoryObj<typeof FilterSheet>;
|
|
42
|
+
|
|
43
|
+
function SampleList() {
|
|
44
|
+
return (
|
|
45
|
+
<ul className="divide-y divide-line">
|
|
46
|
+
{Array.from({ length: 12 }, (_, i) => (
|
|
47
|
+
// biome-ignore lint/suspicious/noArrayIndexKey: list is static, no stable id
|
|
48
|
+
<li key={i} className="px-row-inset py-3 text-sm text-fg-muted">
|
|
49
|
+
Message {i + 1}
|
|
50
|
+
</li>
|
|
51
|
+
))}
|
|
52
|
+
</ul>
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function ControlledShell({
|
|
57
|
+
initialCategory = "all",
|
|
58
|
+
initialFilters = new Set<string>(),
|
|
59
|
+
initialExpanded = true,
|
|
60
|
+
initialSource = "all",
|
|
61
|
+
withSources = true,
|
|
62
|
+
singleAccount = false,
|
|
63
|
+
sourcesNote,
|
|
64
|
+
}: {
|
|
65
|
+
initialCategory?: string;
|
|
66
|
+
initialFilters?: Set<string>;
|
|
67
|
+
initialExpanded?: boolean;
|
|
68
|
+
initialSource?: string;
|
|
69
|
+
withSources?: boolean;
|
|
70
|
+
singleAccount?: boolean;
|
|
71
|
+
sourcesNote?: string;
|
|
72
|
+
}) {
|
|
73
|
+
const [category, setCategory] = useState(initialCategory);
|
|
74
|
+
const [activeFilters, setActiveFilters] =
|
|
75
|
+
useState<Set<string>>(initialFilters);
|
|
76
|
+
const [expanded, setExpanded] = useState(initialExpanded);
|
|
77
|
+
const [source, setSource] = useState(initialSource);
|
|
78
|
+
|
|
79
|
+
const sourcePool = singleAccount ? SOURCES.slice(0, 1) : SOURCES;
|
|
80
|
+
const sources = withSources
|
|
81
|
+
? sourcePool.map((s) => ({ ...s, active: s.id === source }))
|
|
82
|
+
: undefined;
|
|
83
|
+
|
|
84
|
+
return (
|
|
85
|
+
<div className="h-[600px] w-[390px]">
|
|
86
|
+
<FilterSheet
|
|
87
|
+
categories={CATEGORIES}
|
|
88
|
+
filters={FILTERS}
|
|
89
|
+
sources={sources}
|
|
90
|
+
sourcesNote={sourcesNote}
|
|
91
|
+
selectedCategory={category}
|
|
92
|
+
activeFilters={activeFilters}
|
|
93
|
+
expanded={expanded}
|
|
94
|
+
onSelectCategory={setCategory}
|
|
95
|
+
onSelectSource={setSource}
|
|
96
|
+
onToggleFilter={(id) =>
|
|
97
|
+
setActiveFilters((prev) => {
|
|
98
|
+
const next = new Set(prev);
|
|
99
|
+
if (next.has(id)) next.delete(id);
|
|
100
|
+
else next.add(id);
|
|
101
|
+
return next;
|
|
102
|
+
})
|
|
103
|
+
}
|
|
104
|
+
onClear={() => {
|
|
105
|
+
setCategory("all");
|
|
106
|
+
setActiveFilters(new Set());
|
|
107
|
+
}}
|
|
108
|
+
onExpandedChange={setExpanded}
|
|
109
|
+
>
|
|
110
|
+
<SampleList />
|
|
111
|
+
</FilterSheet>
|
|
112
|
+
</div>
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export const Expanded: Story = {
|
|
117
|
+
render: () => <ControlledShell initialExpanded={true} />,
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
export const CollapsedWithActiveFilters: Story = {
|
|
121
|
+
render: () => (
|
|
122
|
+
<ControlledShell
|
|
123
|
+
initialExpanded={false}
|
|
124
|
+
initialCategory="personal"
|
|
125
|
+
initialFilters={new Set(["unread", "today"])}
|
|
126
|
+
/>
|
|
127
|
+
),
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
export const CollapsedEmpty: Story = {
|
|
131
|
+
render: () => <ControlledShell initialExpanded={false} />,
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
export const ExpandedSourceSelected: Story = {
|
|
135
|
+
render: () => <ControlledShell initialSource="work" sourcesNote="+2 muted" />,
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
export const CollapsedWithSourceSelected: Story = {
|
|
139
|
+
render: () => (
|
|
140
|
+
<ControlledShell initialExpanded={false} initialSource="work" />
|
|
141
|
+
),
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
export const SingleAccount: Story = {
|
|
145
|
+
render: () => <ControlledShell singleAccount />,
|
|
146
|
+
};
|