@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,267 @@
|
|
|
1
|
+
import { createContext, type ReactNode, useContext, useState } from "react";
|
|
2
|
+
import {
|
|
3
|
+
type PaneLayout,
|
|
4
|
+
resolvePaneLayout,
|
|
5
|
+
useContainerWidth,
|
|
6
|
+
} from "./app-shell-types.js";
|
|
7
|
+
import { Dialog } from "./dialog.js";
|
|
8
|
+
import {
|
|
9
|
+
ResizableHandle,
|
|
10
|
+
ResizablePanel,
|
|
11
|
+
ResizablePanelGroup,
|
|
12
|
+
} from "./resizable.js";
|
|
13
|
+
|
|
14
|
+
/* ------------------------------------------------------------------ */
|
|
15
|
+
/* Slot-based shell: the same responsive 4-pane arrangement as */
|
|
16
|
+
/* AppShell but with ReactNode render slots instead of data props. */
|
|
17
|
+
/* The web-client passes live data-bound children; the kit preview */
|
|
18
|
+
/* passes its own static components via the data-driven AppShell. */
|
|
19
|
+
/* ------------------------------------------------------------------ */
|
|
20
|
+
|
|
21
|
+
export interface AppShellSlottedProps {
|
|
22
|
+
/**
|
|
23
|
+
* Nav sidebar content. Rendered as a persistent pane ≥1024px or as a
|
|
24
|
+
* dismissible slide-over below that. Required — the shell always has a nav.
|
|
25
|
+
*/
|
|
26
|
+
nav: ReactNode;
|
|
27
|
+
/**
|
|
28
|
+
* List pane content: the message list, daily brief, outbox list, etc.
|
|
29
|
+
* Always visible at all widths (the single pane on narrow layouts).
|
|
30
|
+
*/
|
|
31
|
+
list: ReactNode;
|
|
32
|
+
/**
|
|
33
|
+
* Reading pane content: the conversation view + toolbar. Appears ≥1024px.
|
|
34
|
+
* When absent the reading panel is not shown even if width allows.
|
|
35
|
+
*/
|
|
36
|
+
reading?: ReactNode;
|
|
37
|
+
/**
|
|
38
|
+
* Intelligence rail content. Appears ≥1280px when `intelligenceOpen` is true.
|
|
39
|
+
* When absent the intelligence panel is not shown even if width allows.
|
|
40
|
+
*/
|
|
41
|
+
intelligence?: ReactNode;
|
|
42
|
+
/** Show the intelligence pane (user preference). Defaults to true. */
|
|
43
|
+
intelligenceOpen?: boolean;
|
|
44
|
+
/**
|
|
45
|
+
* Guards the intelligence rail against showing with no open thread. The rail
|
|
46
|
+
* needs both width AND a thread; pass false to keep it closed even when wide
|
|
47
|
+
* and `intelligenceOpen` (an edge state). Defaults to true (caller asserts a
|
|
48
|
+
* thread, e.g. the live app only opens the rail with a selected message).
|
|
49
|
+
*/
|
|
50
|
+
hasThread?: boolean;
|
|
51
|
+
/**
|
|
52
|
+
* List density. "compact" widens the list pane's default split (denser rows
|
|
53
|
+
* read better with more horizontal room); "comfortable" (default) uses the
|
|
54
|
+
* standard split. Only affects the two-pane (list + reading) default size.
|
|
55
|
+
*/
|
|
56
|
+
density?: "comfortable" | "compact";
|
|
57
|
+
/**
|
|
58
|
+
* Header rendered only on narrow (< 1024px) widths — the mobile top bar.
|
|
59
|
+
* The web-client injects the app-specific bar (hamburger / title / search).
|
|
60
|
+
*/
|
|
61
|
+
header?: ReactNode;
|
|
62
|
+
/**
|
|
63
|
+
* Content rendered outside the pane group (e.g., the compose FAB). Floats
|
|
64
|
+
* over the layout regardless of width.
|
|
65
|
+
*/
|
|
66
|
+
overlay?: ReactNode;
|
|
67
|
+
/**
|
|
68
|
+
* Cold-load placeholder. Shown instead of the normal layout while `isLoading`
|
|
69
|
+
* is true.
|
|
70
|
+
*/
|
|
71
|
+
skeleton?: ReactNode;
|
|
72
|
+
/** When true, shows `skeleton` instead of the panel layout. Defaults to false. */
|
|
73
|
+
isLoading?: boolean;
|
|
74
|
+
/**
|
|
75
|
+
* Seed width (px) for the container-query reflow before the first
|
|
76
|
+
* ResizeObserver measure (SSR / pre-mount).
|
|
77
|
+
*/
|
|
78
|
+
initialWidth?: number;
|
|
79
|
+
/**
|
|
80
|
+
* Externally controlled nav-open state (narrow widths). When absent the
|
|
81
|
+
* shell manages it internally.
|
|
82
|
+
*/
|
|
83
|
+
navOpen?: boolean;
|
|
84
|
+
/** Called when the nav slide-over should open. */
|
|
85
|
+
onOpenNav?: () => void;
|
|
86
|
+
/** Called when the nav slide-over should close. */
|
|
87
|
+
onCloseNav?: () => void;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/* ------------------------------------------------------------------ */
|
|
91
|
+
/* Pane-layout context */
|
|
92
|
+
/* ------------------------------------------------------------------ */
|
|
93
|
+
|
|
94
|
+
/** Published by `AppShellSlotted`; consumed by `useAppShellLayout`. */
|
|
95
|
+
export interface AppShellLayoutContext {
|
|
96
|
+
/** Current pane visibility derived from the shell's own-width. */
|
|
97
|
+
panes: PaneLayout;
|
|
98
|
+
/** Own-width in px; null before the first ResizeObserver measure. */
|
|
99
|
+
containerWidth: number | null;
|
|
100
|
+
/** True when the nav is a persistent pane (not a slide-over). */
|
|
101
|
+
showNavPane: boolean;
|
|
102
|
+
/** Open the nav slide-over. Call from list-header "folders" buttons. */
|
|
103
|
+
openNav: () => void;
|
|
104
|
+
/** True when the reading pane is active (width ≥ 1024px). */
|
|
105
|
+
showReadingPane: boolean;
|
|
106
|
+
/** True when the intelligence rail can show (width ≥ 1280px). */
|
|
107
|
+
showIntelligencePane: boolean;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const AppShellLayoutCtx = createContext<AppShellLayoutContext | null>(null);
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Read the enclosing `AppShellSlotted`'s pane layout.
|
|
114
|
+
* Returns null outside of an `AppShellSlotted` (tests / Storybook).
|
|
115
|
+
*/
|
|
116
|
+
export function useAppShellLayout(): AppShellLayoutContext | null {
|
|
117
|
+
return useContext(AppShellLayoutCtx);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/* ------------------------------------------------------------------ */
|
|
121
|
+
/* AppShellSlotted */
|
|
122
|
+
/* ------------------------------------------------------------------ */
|
|
123
|
+
|
|
124
|
+
export function AppShellSlotted({
|
|
125
|
+
nav,
|
|
126
|
+
list,
|
|
127
|
+
reading,
|
|
128
|
+
intelligence,
|
|
129
|
+
intelligenceOpen = true,
|
|
130
|
+
hasThread = true,
|
|
131
|
+
density = "comfortable",
|
|
132
|
+
header,
|
|
133
|
+
overlay,
|
|
134
|
+
skeleton,
|
|
135
|
+
isLoading = false,
|
|
136
|
+
initialWidth,
|
|
137
|
+
navOpen: navOpenProp,
|
|
138
|
+
onOpenNav,
|
|
139
|
+
onCloseNav,
|
|
140
|
+
}: AppShellSlottedProps) {
|
|
141
|
+
const [containerRef, containerWidth] = useContainerWidth(initialWidth);
|
|
142
|
+
const panes = resolvePaneLayout(containerWidth ?? 0);
|
|
143
|
+
const showNavPane = panes.nav;
|
|
144
|
+
const showReadingPane = panes.reading && Boolean(reading);
|
|
145
|
+
const isWide = panes.intelligence;
|
|
146
|
+
|
|
147
|
+
const [navOpenInternal, setNavOpenInternal] = useState(false);
|
|
148
|
+
const controlled = navOpenProp !== undefined;
|
|
149
|
+
const navOpen = controlled ? navOpenProp : navOpenInternal;
|
|
150
|
+
const openNav = () => {
|
|
151
|
+
if (!controlled) setNavOpenInternal(true);
|
|
152
|
+
onOpenNav?.();
|
|
153
|
+
};
|
|
154
|
+
const closeNav = () => {
|
|
155
|
+
if (!controlled) setNavOpenInternal(false);
|
|
156
|
+
onCloseNav?.();
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
const showIntelligencePanel =
|
|
160
|
+
isWide && intelligenceOpen && hasThread && Boolean(intelligence);
|
|
161
|
+
|
|
162
|
+
const layoutCtx: AppShellLayoutContext = {
|
|
163
|
+
panes,
|
|
164
|
+
containerWidth,
|
|
165
|
+
showNavPane,
|
|
166
|
+
openNav,
|
|
167
|
+
showReadingPane,
|
|
168
|
+
showIntelligencePane: isWide,
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
return (
|
|
172
|
+
<AppShellLayoutCtx.Provider value={layoutCtx}>
|
|
173
|
+
<div
|
|
174
|
+
ref={containerRef}
|
|
175
|
+
className="@container flex h-dvh w-full flex-col overflow-hidden bg-canvas font-sans text-fg"
|
|
176
|
+
>
|
|
177
|
+
{isLoading && skeleton ? (
|
|
178
|
+
skeleton
|
|
179
|
+
) : (
|
|
180
|
+
<>
|
|
181
|
+
{/* Narrow top bar: rendered only when the nav is a slide-over
|
|
182
|
+
(< 1024px). Desktop has no slim bar. */}
|
|
183
|
+
{!showNavPane && header && <div className="shrink-0">{header}</div>}
|
|
184
|
+
|
|
185
|
+
<ResizablePanelGroup
|
|
186
|
+
direction="horizontal"
|
|
187
|
+
className="min-h-0 flex-1"
|
|
188
|
+
>
|
|
189
|
+
{showNavPane && (
|
|
190
|
+
<>
|
|
191
|
+
<ResizablePanel
|
|
192
|
+
id="nav"
|
|
193
|
+
order={1}
|
|
194
|
+
defaultSize={17}
|
|
195
|
+
minSize={12}
|
|
196
|
+
maxSize={24}
|
|
197
|
+
className="min-w-0"
|
|
198
|
+
>
|
|
199
|
+
{nav}
|
|
200
|
+
</ResizablePanel>
|
|
201
|
+
<ResizableHandle />
|
|
202
|
+
</>
|
|
203
|
+
)}
|
|
204
|
+
|
|
205
|
+
<ResizablePanel
|
|
206
|
+
id="list"
|
|
207
|
+
order={2}
|
|
208
|
+
defaultSize={
|
|
209
|
+
showReadingPane ? (density === "compact" ? 36 : 27) : 83
|
|
210
|
+
}
|
|
211
|
+
minSize={18}
|
|
212
|
+
maxSize={showReadingPane ? 48 : 88}
|
|
213
|
+
className="min-w-0"
|
|
214
|
+
>
|
|
215
|
+
{list}
|
|
216
|
+
</ResizablePanel>
|
|
217
|
+
|
|
218
|
+
{showReadingPane && (
|
|
219
|
+
<>
|
|
220
|
+
<ResizableHandle />
|
|
221
|
+
<ResizablePanel
|
|
222
|
+
id="reading"
|
|
223
|
+
order={3}
|
|
224
|
+
minSize={24}
|
|
225
|
+
className="min-w-0"
|
|
226
|
+
>
|
|
227
|
+
{reading}
|
|
228
|
+
</ResizablePanel>
|
|
229
|
+
</>
|
|
230
|
+
)}
|
|
231
|
+
|
|
232
|
+
{showIntelligencePanel && (
|
|
233
|
+
<>
|
|
234
|
+
<ResizableHandle />
|
|
235
|
+
<ResizablePanel
|
|
236
|
+
id="intelligence"
|
|
237
|
+
order={4}
|
|
238
|
+
defaultSize={21}
|
|
239
|
+
minSize={15}
|
|
240
|
+
maxSize={32}
|
|
241
|
+
className="min-w-0"
|
|
242
|
+
>
|
|
243
|
+
{intelligence}
|
|
244
|
+
</ResizablePanel>
|
|
245
|
+
</>
|
|
246
|
+
)}
|
|
247
|
+
</ResizablePanelGroup>
|
|
248
|
+
|
|
249
|
+
{/* Narrow nav: dismissible slide-over (#784). */}
|
|
250
|
+
{!showNavPane && (
|
|
251
|
+
<Dialog
|
|
252
|
+
open={navOpen}
|
|
253
|
+
onClose={closeNav}
|
|
254
|
+
title="Folders"
|
|
255
|
+
anchor="left"
|
|
256
|
+
>
|
|
257
|
+
{nav}
|
|
258
|
+
</Dialog>
|
|
259
|
+
)}
|
|
260
|
+
|
|
261
|
+
{overlay}
|
|
262
|
+
</>
|
|
263
|
+
)}
|
|
264
|
+
</div>
|
|
265
|
+
</AppShellLayoutCtx.Provider>
|
|
266
|
+
);
|
|
267
|
+
}
|
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type ReactElement,
|
|
3
|
+
type ReactNode,
|
|
4
|
+
type RefObject,
|
|
5
|
+
useEffect,
|
|
6
|
+
useRef,
|
|
7
|
+
useState,
|
|
8
|
+
} from "react";
|
|
9
|
+
import type {
|
|
10
|
+
IntelligenceData,
|
|
11
|
+
SenderTrustLevel,
|
|
12
|
+
} from "./intelligence-panel.js";
|
|
13
|
+
import type { ListState } from "./message-list-state.js";
|
|
14
|
+
|
|
15
|
+
/** Pane-count thresholds, aligned to Tailwind `lg`/`xl`. The whole shell reflows
|
|
16
|
+
* by its own width: a single responsive surface, not per-device variants.
|
|
17
|
+
* < 1024px (below `lg`) → list pane only (phone + tablet portrait)
|
|
18
|
+
* 1024–1279px (`lg`) → list + reading pane (two-pane)
|
|
19
|
+
* ≥ 1280px (`xl`) → + intelligence rail (three-pane, when present)
|
|
20
|
+
*/
|
|
21
|
+
export const READING_PANE_MIN_WIDTH = 1024;
|
|
22
|
+
export const INTELLIGENCE_MIN_WIDTH = 1280;
|
|
23
|
+
|
|
24
|
+
/** Which panes the shell shows at a given viewport width. Pure (no DOM) so the
|
|
25
|
+
* pane-count-by-width rule is testable. `intelligence` is gated additionally on
|
|
26
|
+
* there being intelligence data + an open thread at render time; this is the
|
|
27
|
+
* width ceiling only. */
|
|
28
|
+
export interface PaneLayout {
|
|
29
|
+
nav: boolean;
|
|
30
|
+
reading: boolean;
|
|
31
|
+
intelligence: boolean;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The single source of truth for the reflow rule:
|
|
36
|
+
* < readingPaneMinWidth → list alone (phone + tablet PORTRAIT) — no reading pane
|
|
37
|
+
* readingPaneMinWidth–intelligenceMinWidth → list + reading (tablet landscape / desktop)
|
|
38
|
+
* ≥ intelligenceMinWidth → + intelligence rail (widest)
|
|
39
|
+
* The persistent nav pane shares the reading-pane boundary; below it the nav is
|
|
40
|
+
* a slide-over, not a column.
|
|
41
|
+
*
|
|
42
|
+
* Both thresholds are configurable so a consumer can pass the desired pixel
|
|
43
|
+
* values without forking the layout logic. The kit `AppShell` uses the
|
|
44
|
+
* module-level defaults (1024 / 1280).
|
|
45
|
+
*/
|
|
46
|
+
export function resolvePaneLayout(
|
|
47
|
+
width: number,
|
|
48
|
+
readingPaneMinWidth = READING_PANE_MIN_WIDTH,
|
|
49
|
+
intelligenceMinWidth = INTELLIGENCE_MIN_WIDTH,
|
|
50
|
+
): PaneLayout {
|
|
51
|
+
const reading = width >= readingPaneMinWidth;
|
|
52
|
+
return {
|
|
53
|
+
nav: reading,
|
|
54
|
+
reading,
|
|
55
|
+
intelligence: width >= intelligenceMinWidth,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export type NarrowView = "list" | "message";
|
|
60
|
+
|
|
61
|
+
/** Seeds the narrow touch list's interaction state for stories / SSR, so it can
|
|
62
|
+
* render selection mode or a swipe-peeked row without a live gesture. */
|
|
63
|
+
export type TouchSeed = "selection" | "peek-trailing" | "peek-leading";
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Measures an element's OWN width via ResizeObserver — a container query, not a
|
|
67
|
+
* viewport one. The shell reflows by the space it actually occupies (so it works
|
|
68
|
+
* embedded at any width, not just full-screen), and the pane count is derived
|
|
69
|
+
* from this with `resolvePaneLayout`. Returns [ref, width]; width is `null`
|
|
70
|
+
* until the first measure (SSR / pre-mount), where the shell renders list-only.
|
|
71
|
+
*/
|
|
72
|
+
export function useContainerWidth(
|
|
73
|
+
seed?: number,
|
|
74
|
+
): [RefObject<HTMLDivElement | null>, number | null] {
|
|
75
|
+
const ref = useRef<HTMLDivElement | null>(null);
|
|
76
|
+
const [width, setWidth] = useState<number | null>(seed ?? null);
|
|
77
|
+
useEffect(() => {
|
|
78
|
+
const el = ref.current;
|
|
79
|
+
if (!el || typeof ResizeObserver === "undefined") return;
|
|
80
|
+
const observer = new ResizeObserver((entries) => {
|
|
81
|
+
const entry = entries[0];
|
|
82
|
+
if (entry) setWidth(entry.contentRect.width);
|
|
83
|
+
});
|
|
84
|
+
observer.observe(el);
|
|
85
|
+
setWidth(el.getBoundingClientRect().width);
|
|
86
|
+
return () => observer.disconnect();
|
|
87
|
+
}, []);
|
|
88
|
+
return [ref, width];
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* System-folder role for a sidebar mailbox, resolved by the web-client adapter —
|
|
93
|
+
* the single detection path (see `mailbox-order.ts`). Absent = a custom user
|
|
94
|
+
* folder. The kit pins, orders, and icons system folders purely by this role and
|
|
95
|
+
* never inspects raw IMAP SPECIAL-USE strings itself. "inbox" is included even
|
|
96
|
+
* though INBOX carries no SPECIAL-USE attribute (it is matched by name).
|
|
97
|
+
*/
|
|
98
|
+
export type NavMailboxRole =
|
|
99
|
+
| "inbox"
|
|
100
|
+
| "flagged"
|
|
101
|
+
| "drafts"
|
|
102
|
+
| "sent"
|
|
103
|
+
| "archive"
|
|
104
|
+
| "all"
|
|
105
|
+
| "junk"
|
|
106
|
+
| "trash";
|
|
107
|
+
|
|
108
|
+
export interface NavMailbox {
|
|
109
|
+
id: string;
|
|
110
|
+
name: string;
|
|
111
|
+
unseen?: number;
|
|
112
|
+
/** System-folder role (adapter-computed). Absent = a custom user folder. */
|
|
113
|
+
role?: NavMailboxRole;
|
|
114
|
+
/**
|
|
115
|
+
* Full mailbox path, surfaced as the row's `title` tooltip so a truncated or
|
|
116
|
+
* localized leaf name still reveals where it lives. Defaults to `name`.
|
|
117
|
+
*/
|
|
118
|
+
fullPath?: string;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Per-account mailbox-load status, so the nav can show loading/error inline. */
|
|
122
|
+
export type NavAccountStatus = "loading" | "error" | "ready";
|
|
123
|
+
|
|
124
|
+
export interface NavAccount {
|
|
125
|
+
id: string;
|
|
126
|
+
label: string;
|
|
127
|
+
email: string;
|
|
128
|
+
/** Muted: excluded from unified views, still syncing. Rendered dimmed. */
|
|
129
|
+
muted?: boolean;
|
|
130
|
+
mailboxes: NavMailbox[];
|
|
131
|
+
/**
|
|
132
|
+
* Number of outbox messages pending send. When provided, an Outbox entry
|
|
133
|
+
* appears below the system mailbox list for this account.
|
|
134
|
+
*/
|
|
135
|
+
outboxPending?: number;
|
|
136
|
+
/**
|
|
137
|
+
* Mailbox-load status. "ready" (default) renders the mailbox list; "loading"
|
|
138
|
+
* shows a placeholder; "error" shows a retry affordance via `onRetry`.
|
|
139
|
+
*/
|
|
140
|
+
status?: NavAccountStatus;
|
|
141
|
+
/** Retry handler for the error state. */
|
|
142
|
+
onRetry?: () => void;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Renders a navigation entry as a real anchor so middle-click / open-in-new-tab
|
|
147
|
+
* / deep-linking / screen-reader link semantics all work. The web-client passes
|
|
148
|
+
* a router `<Link>` builder; when omitted, NavItem falls back to a button with
|
|
149
|
+
* programmatic `onSelectNav` (used by static stories / the AppShell preview).
|
|
150
|
+
*/
|
|
151
|
+
export interface NavLinkRenderProps {
|
|
152
|
+
/** The nav id this entry targets ("brief", "outbox", or a mailbox id). */
|
|
153
|
+
navId: string;
|
|
154
|
+
className: string;
|
|
155
|
+
ariaLabel?: string;
|
|
156
|
+
title?: string;
|
|
157
|
+
children: ReactNode;
|
|
158
|
+
onClick?: () => void;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export type NavLinkComponent = (props: NavLinkRenderProps) => ReactElement;
|
|
162
|
+
|
|
163
|
+
export type ThreadCategory =
|
|
164
|
+
| "personal"
|
|
165
|
+
| "newsletter"
|
|
166
|
+
| "marketing"
|
|
167
|
+
| "automated"
|
|
168
|
+
| "transactional"
|
|
169
|
+
| "social";
|
|
170
|
+
|
|
171
|
+
/** "all" (no category narrowing) plus every content-type category. */
|
|
172
|
+
export type BriefCategoryFilter = ThreadCategory | "all";
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Ordered content-type categories for the brief expando. Mirrors the generated
|
|
176
|
+
* `MessageCategory` enum (@remit/domain-enums); swap this local list for the
|
|
177
|
+
* generated enum's values once that package is importable from the UI build.
|
|
178
|
+
*/
|
|
179
|
+
export const briefCategories: ReadonlyArray<{
|
|
180
|
+
id: BriefCategoryFilter;
|
|
181
|
+
label: string;
|
|
182
|
+
}> = [
|
|
183
|
+
{ id: "all", label: "All" },
|
|
184
|
+
{ id: "personal", label: "Personal" },
|
|
185
|
+
{ id: "newsletter", label: "Newsletters" },
|
|
186
|
+
{ id: "marketing", label: "Marketing" },
|
|
187
|
+
{ id: "automated", label: "Automated" },
|
|
188
|
+
{ id: "transactional", label: "Transactional" },
|
|
189
|
+
{ id: "social", label: "Social" },
|
|
190
|
+
];
|
|
191
|
+
|
|
192
|
+
export interface ThreadRowData {
|
|
193
|
+
id: string;
|
|
194
|
+
accountId: string;
|
|
195
|
+
/** Owning mailbox — used by the `in:` search-token filter. */
|
|
196
|
+
mailboxId?: string;
|
|
197
|
+
fromName: string;
|
|
198
|
+
fromEmail: string;
|
|
199
|
+
subject: string;
|
|
200
|
+
snippet: string;
|
|
201
|
+
timeLabel: string;
|
|
202
|
+
/** Unix epoch ms — used by the "Today" brief filter; absent in fixture rows. */
|
|
203
|
+
sentDate?: number;
|
|
204
|
+
isRead?: boolean;
|
|
205
|
+
hasAttachment?: boolean;
|
|
206
|
+
starred?: boolean;
|
|
207
|
+
trust?: SenderTrustLevel;
|
|
208
|
+
category?: ThreadCategory;
|
|
209
|
+
/** Number of messages when the row is a thread. */
|
|
210
|
+
messageCount?: number;
|
|
211
|
+
/** Authenticity heuristics flagged this row (DKIM/From mismatch). */
|
|
212
|
+
suspicious?: boolean;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export interface ThreadSection {
|
|
216
|
+
id: string;
|
|
217
|
+
/** Section label; omit for a flat list. */
|
|
218
|
+
label?: string;
|
|
219
|
+
threads: ThreadRowData[];
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export interface ThreadMessageData {
|
|
223
|
+
id: string;
|
|
224
|
+
fromName: string;
|
|
225
|
+
fromEmail: string;
|
|
226
|
+
toLabel: string;
|
|
227
|
+
dateLabel: string;
|
|
228
|
+
snippet: string;
|
|
229
|
+
bodyHtml: string;
|
|
230
|
+
expanded?: boolean;
|
|
231
|
+
/**
|
|
232
|
+
* Designed HTML mail (newsletters/marketing): render the body inside a
|
|
233
|
+
* hairline content frame that hugs left. The email keeps its own colors
|
|
234
|
+
* inside the frame (never dark-inverted); the frame contains the
|
|
235
|
+
* brightness so a white 600px blast doesn't glow to the pane edge.
|
|
236
|
+
*/
|
|
237
|
+
framed?: boolean;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
export interface ThreadData {
|
|
241
|
+
subject: string;
|
|
242
|
+
messages: ThreadMessageData[];
|
|
243
|
+
/** Danger banner above the body (authenticity verdicts only). */
|
|
244
|
+
warning?: string;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
export type Density = "comfortable" | "compact";
|
|
248
|
+
|
|
249
|
+
export interface AppShellProps {
|
|
250
|
+
accounts: NavAccount[];
|
|
251
|
+
/**
|
|
252
|
+
* Seed width (px) for the container-query reflow before the first
|
|
253
|
+
* ResizeObserver measure. Lets SSR / tests render the correct pane count
|
|
254
|
+
* without a live layout pass; once mounted, the observed own-width takes over.
|
|
255
|
+
*/
|
|
256
|
+
initialWidth?: number;
|
|
257
|
+
/** "brief" or a mailbox id. */
|
|
258
|
+
selectedNavId: string;
|
|
259
|
+
briefUnseen?: number;
|
|
260
|
+
listTitle: string;
|
|
261
|
+
listMeta?: string;
|
|
262
|
+
sections: ThreadSection[];
|
|
263
|
+
/** Brief mode: collapsible section headers + a composable filter chip bar. */
|
|
264
|
+
briefFilters?: boolean;
|
|
265
|
+
/**
|
|
266
|
+
* Flat plain-mailbox list (no section labels, no chip bar) — the shape the
|
|
267
|
+
* live `$mailboxId` MessageList renders. Defaults to the sectioned brief.
|
|
268
|
+
*/
|
|
269
|
+
flatList?: boolean;
|
|
270
|
+
/**
|
|
271
|
+
* Drives the list pane's state. "ready" (default) renders rows; the other
|
|
272
|
+
* states render the loading skeleton / empty / error surfaces in place of
|
|
273
|
+
* the rows, mirroring the live MessageList.
|
|
274
|
+
*/
|
|
275
|
+
listState?: ListState;
|
|
276
|
+
/** Active search query — switches the empty state to its search variant. */
|
|
277
|
+
searchQuery?: string;
|
|
278
|
+
/**
|
|
279
|
+
* Specific failure detail for the error state. Surfaced verbatim under the
|
|
280
|
+
* generic headline so the failure is readable (ux.md fail-loud), not a bare
|
|
281
|
+
* "something went wrong". Omit to fall back to the generic copy.
|
|
282
|
+
*/
|
|
283
|
+
errorMessage?: string;
|
|
284
|
+
/** Retry handler for the error state. */
|
|
285
|
+
onRetry?: () => void;
|
|
286
|
+
/** Report handler for the error state (the failure goes somewhere). */
|
|
287
|
+
onReportError?: () => void;
|
|
288
|
+
/**
|
|
289
|
+
* Content-type category filter for the brief (a separate axis from the
|
|
290
|
+
* in-list chips). Selecting one narrows the brief to that category.
|
|
291
|
+
*/
|
|
292
|
+
briefCategory?: BriefCategoryFilter;
|
|
293
|
+
onSelectBriefCategory?: (category: BriefCategoryFilter) => void;
|
|
294
|
+
selectedThreadId?: string;
|
|
295
|
+
thread?: ThreadData;
|
|
296
|
+
/**
|
|
297
|
+
* Seed for the narrow single-pane view (the list, or the dedicated message
|
|
298
|
+
* view). Lets a story / SSR open straight to a thread without a click. Only
|
|
299
|
+
* meaningful below the reading boundary; ignored once the reading pane fits.
|
|
300
|
+
*/
|
|
301
|
+
initialNarrowView?: NarrowView;
|
|
302
|
+
/**
|
|
303
|
+
* Seed the narrow touch list directly in selection mode or with a
|
|
304
|
+
* swipe-peeked row, so a story / SSR can show those triage states statically.
|
|
305
|
+
* Only meaningful below the reading boundary; ignored once the reading pane
|
|
306
|
+
* fits or when the message view is showing.
|
|
307
|
+
*/
|
|
308
|
+
initialTouchState?: TouchSeed;
|
|
309
|
+
intelligence?: IntelligenceData;
|
|
310
|
+
/** Pane 4 visible. Defaults to true when intelligence is present. */
|
|
311
|
+
intelligenceOpen?: boolean;
|
|
312
|
+
density?: Density;
|
|
313
|
+
onSelectNav?: (id: string) => void;
|
|
314
|
+
onSelectThread?: (id: string) => void;
|
|
315
|
+
onToggleIntelligence?: () => void;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
export const categoryTone: Record<
|
|
319
|
+
ThreadCategory,
|
|
320
|
+
"neutral" | "accent" | "positive" | "warning"
|
|
321
|
+
> = {
|
|
322
|
+
personal: "accent",
|
|
323
|
+
newsletter: "neutral",
|
|
324
|
+
marketing: "neutral",
|
|
325
|
+
automated: "neutral",
|
|
326
|
+
transactional: "positive",
|
|
327
|
+
social: "warning",
|
|
328
|
+
};
|