@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.
Files changed (188) hide show
  1. package/package.json +50 -0
  2. package/src/components/address-display.render.test.ts +93 -0
  3. package/src/components/address-display.stories.tsx +55 -0
  4. package/src/components/address-display.tsx +132 -0
  5. package/src/components/address-tag.render.test.ts +39 -0
  6. package/src/components/address-tag.stories.tsx +41 -0
  7. package/src/components/address-tag.tsx +38 -0
  8. package/src/components/app-password-hint.render.test.ts +23 -0
  9. package/src/components/app-password-hint.stories.tsx +19 -0
  10. package/src/components/app-password-hint.tsx +24 -0
  11. package/src/components/app-shell-slotted.render.test.ts +128 -0
  12. package/src/components/app-shell-slotted.tsx +267 -0
  13. package/src/components/app-shell-types.ts +328 -0
  14. package/src/components/app-shell.render.test.ts +415 -0
  15. package/src/components/app-shell.tsx +277 -0
  16. package/src/components/auth-card.render.test.ts +24 -0
  17. package/src/components/auth-card.stories.tsx +50 -0
  18. package/src/components/auth-card.tsx +32 -0
  19. package/src/components/auth-footer.render.test.ts +17 -0
  20. package/src/components/auth-footer.stories.tsx +23 -0
  21. package/src/components/auth-footer.tsx +14 -0
  22. package/src/components/auth-hero.render.test.ts +31 -0
  23. package/src/components/auth-hero.stories.tsx +19 -0
  24. package/src/components/auth-hero.tsx +41 -0
  25. package/src/components/auto-moved-badge.render.test.ts +42 -0
  26. package/src/components/auto-moved-badge.stories.tsx +48 -0
  27. package/src/components/auto-moved-badge.tsx +57 -0
  28. package/src/components/avatar.tsx +60 -0
  29. package/src/components/badge.tsx +49 -0
  30. package/src/components/banner.render.test.ts +58 -0
  31. package/src/components/banner.stories.tsx +92 -0
  32. package/src/components/banner.tsx +81 -0
  33. package/src/components/bottom-sheet.render.test.ts +48 -0
  34. package/src/components/bottom-sheet.stories.tsx +71 -0
  35. package/src/components/bottom-sheet.tsx +146 -0
  36. package/src/components/brief-section.render.test.ts +98 -0
  37. package/src/components/brief-section.stories.tsx +68 -0
  38. package/src/components/brief-section.tsx +102 -0
  39. package/src/components/brief-sections.render.test.ts +72 -0
  40. package/src/components/brief-sections.stories.tsx +178 -0
  41. package/src/components/brief-sections.tsx +201 -0
  42. package/src/components/button.tsx +48 -0
  43. package/src/components/card.tsx +48 -0
  44. package/src/components/category-badge.render.test.ts +40 -0
  45. package/src/components/category-badge.stories.tsx +37 -0
  46. package/src/components/category-badge.tsx +71 -0
  47. package/src/components/checkbox.render.test.ts +65 -0
  48. package/src/components/checkbox.stories.tsx +77 -0
  49. package/src/components/checkbox.tsx +78 -0
  50. package/src/components/compose-action-bar.render.test.ts +47 -0
  51. package/src/components/compose-action-bar.stories.tsx +35 -0
  52. package/src/components/compose-action-bar.tsx +95 -0
  53. package/src/components/compose-form-shell.render.test.ts +54 -0
  54. package/src/components/compose-form-shell.stories.tsx +96 -0
  55. package/src/components/compose-form-shell.tsx +49 -0
  56. package/src/components/danger-zone-section.tsx +36 -0
  57. package/src/components/dialog.render.test.ts +55 -0
  58. package/src/components/dialog.tsx +104 -0
  59. package/src/components/email-frame-css.ts +137 -0
  60. package/src/components/field-label.render.test.ts +16 -0
  61. package/src/components/field-label.tsx +19 -0
  62. package/src/components/filter-sheet.render.test.ts +76 -0
  63. package/src/components/filter-sheet.stories.tsx +146 -0
  64. package/src/components/filter-sheet.tsx +316 -0
  65. package/src/components/folder-role.tsx +66 -0
  66. package/src/components/input.tsx +44 -0
  67. package/src/components/intelligence-panel.stories.tsx +149 -0
  68. package/src/components/intelligence-panel.tsx +546 -0
  69. package/src/components/isolated-email-frame.render.test.ts +147 -0
  70. package/src/components/isolated-email-frame.stories.tsx +192 -0
  71. package/src/components/isolated-email-frame.tsx +265 -0
  72. package/src/components/kbd.tsx +17 -0
  73. package/src/components/keyboard-hint-bar.tsx +58 -0
  74. package/src/components/list-item.tsx +39 -0
  75. package/src/components/mail-action-toolbar.render.test.ts +43 -0
  76. package/src/components/mail-action-toolbar.tsx +180 -0
  77. package/src/components/mail-header.render.test.ts +69 -0
  78. package/src/components/mail-header.stories.tsx +233 -0
  79. package/src/components/mail-header.tsx +123 -0
  80. package/src/components/message-body-view.render.test.ts +59 -0
  81. package/src/components/message-body-view.stories.tsx +146 -0
  82. package/src/components/message-body-view.tsx +150 -0
  83. package/src/components/message-header.render.test.ts +58 -0
  84. package/src/components/message-header.stories.tsx +79 -0
  85. package/src/components/message-header.tsx +59 -0
  86. package/src/components/message-list-pane.render.test.ts +136 -0
  87. package/src/components/message-list-pane.stories.tsx +246 -0
  88. package/src/components/message-list-pane.tsx +248 -0
  89. package/src/components/message-list-state.render.test.ts +72 -0
  90. package/src/components/message-list-state.tsx +97 -0
  91. package/src/components/message-row.render.test.ts +48 -0
  92. package/src/components/message-row.stories.tsx +121 -0
  93. package/src/components/message-row.tsx +231 -0
  94. package/src/components/mobile-message-action-bar.render.test.ts +87 -0
  95. package/src/components/mobile-message-action-bar.stories.tsx +57 -0
  96. package/src/components/mobile-message-action-bar.tsx +175 -0
  97. package/src/components/mobile-reading-pane.render.test.ts +102 -0
  98. package/src/components/mobile-reading-pane.tsx +188 -0
  99. package/src/components/mobile-search-view.stories.tsx +247 -0
  100. package/src/components/mobile-search-view.tsx +100 -0
  101. package/src/components/move-mailbox-picker.render.test.ts +113 -0
  102. package/src/components/move-mailbox-picker.stories.tsx +76 -0
  103. package/src/components/move-mailbox-picker.tsx +324 -0
  104. package/src/components/nav-sidebar.render.test.ts +351 -0
  105. package/src/components/nav-sidebar.stories.tsx +216 -0
  106. package/src/components/nav-sidebar.tsx +625 -0
  107. package/src/components/outbox-row.render.test.ts +61 -0
  108. package/src/components/outbox-row.stories.tsx +53 -0
  109. package/src/components/outbox-row.tsx +120 -0
  110. package/src/components/outbox-status-badge.tsx +60 -0
  111. package/src/components/pane-layout.render.test.ts +105 -0
  112. package/src/components/popover-menu.render.test.ts +32 -0
  113. package/src/components/popover-menu.stories.tsx +57 -0
  114. package/src/components/popover-menu.tsx +110 -0
  115. package/src/components/primitives.stories.tsx +173 -0
  116. package/src/components/pull-to-refresh.render.test.ts +46 -0
  117. package/src/components/pull-to-refresh.stories.tsx +71 -0
  118. package/src/components/pull-to-refresh.tsx +54 -0
  119. package/src/components/quoted-text.render.test.ts +39 -0
  120. package/src/components/quoted-text.stories.tsx +67 -0
  121. package/src/components/quoted-text.tsx +67 -0
  122. package/src/components/reading-pane-empty.tsx +44 -0
  123. package/src/components/reading-pane.render.test.ts +148 -0
  124. package/src/components/reading-pane.stories.tsx +194 -0
  125. package/src/components/reading-pane.tsx +351 -0
  126. package/src/components/rescue-banner.render.test.ts +32 -0
  127. package/src/components/rescue-banner.stories.tsx +31 -0
  128. package/src/components/rescue-banner.tsx +50 -0
  129. package/src/components/rescue-candidate-row.render.test.ts +68 -0
  130. package/src/components/rescue-candidate-row.stories.tsx +108 -0
  131. package/src/components/rescue-candidate-row.tsx +94 -0
  132. package/src/components/rescue-from-spam-flow.render.test.ts +100 -0
  133. package/src/components/rescue-from-spam-flow.stories.tsx +92 -0
  134. package/src/components/rescue-from-spam-flow.tsx +342 -0
  135. package/src/components/resizable.tsx +55 -0
  136. package/src/components/role-appointment-list.render.test.ts +97 -0
  137. package/src/components/role-appointment-list.stories.tsx +138 -0
  138. package/src/components/role-appointment-list.tsx +227 -0
  139. package/src/components/row-actions.render.test.ts +83 -0
  140. package/src/components/row-actions.stories.tsx +87 -0
  141. package/src/components/row-actions.tsx +139 -0
  142. package/src/components/search-bar.render.test.ts +55 -0
  143. package/src/components/search-bar.stories.tsx +46 -0
  144. package/src/components/search-bar.tsx +118 -0
  145. package/src/components/search-result-row.tsx +139 -0
  146. package/src/components/search-results.render.test.ts +89 -0
  147. package/src/components/search-results.stories.tsx +126 -0
  148. package/src/components/search-results.tsx +215 -0
  149. package/src/components/search-token-chip.render.test.ts +42 -0
  150. package/src/components/search-token-chip.stories.tsx +42 -0
  151. package/src/components/search-token-chip.tsx +66 -0
  152. package/src/components/security-select.render.test.ts +33 -0
  153. package/src/components/security-select.tsx +53 -0
  154. package/src/components/segmented-control.tsx +70 -0
  155. package/src/components/select.tsx +33 -0
  156. package/src/components/selection-top-bar.render.test.ts +67 -0
  157. package/src/components/selection-top-bar.stories.tsx +39 -0
  158. package/src/components/selection-top-bar.tsx +95 -0
  159. package/src/components/sender-group-switch.tsx +63 -0
  160. package/src/components/sender-trust-indicator.render.test.ts +67 -0
  161. package/src/components/sender-trust-indicator.stories.tsx +58 -0
  162. package/src/components/sender-trust-indicator.tsx +87 -0
  163. package/src/components/settings-primitives.render.test.ts +160 -0
  164. package/src/components/settings-primitives.stories.tsx +80 -0
  165. package/src/components/settings-screen.tsx +354 -0
  166. package/src/components/swipeable-row.render.test.ts +106 -0
  167. package/src/components/swipeable-row.stories.tsx +117 -0
  168. package/src/components/swipeable-row.tsx +268 -0
  169. package/src/components/touch-list-body.render.test.ts +66 -0
  170. package/src/components/touch-list-body.stories.tsx +76 -0
  171. package/src/components/touch-list.tsx +87 -0
  172. package/src/components/wizard.render.test.ts +49 -0
  173. package/src/components/wizard.stories.tsx +113 -0
  174. package/src/components/wizard.tsx +321 -0
  175. package/src/filter-presets.test.ts +93 -0
  176. package/src/filter-presets.ts +101 -0
  177. package/src/index.ts +378 -0
  178. package/src/lib/cid-resolver.ts +35 -0
  179. package/src/lib/cn.ts +7 -0
  180. package/src/lib/email-layout-clamp.test.ts +109 -0
  181. package/src/lib/email-layout-clamp.ts +64 -0
  182. package/src/lib/email-render-treatment.test.ts +82 -0
  183. package/src/lib/email-render-treatment.ts +53 -0
  184. package/src/lib/email-sanitizer.test.ts +362 -0
  185. package/src/lib/email-sanitizer.ts +298 -0
  186. package/src/lib/row-keyboard.test.ts +41 -0
  187. package/src/lib/row-keyboard.ts +20 -0
  188. package/src/tokens.css +217 -0
@@ -0,0 +1,351 @@
1
+ import assert from "node:assert/strict";
2
+ import { afterEach, describe, it } from "node:test";
3
+ import { createElement } from "react";
4
+ import { renderToString } from "react-dom/server";
5
+ import type { NavAccount, NavLinkComponent } from "./app-shell-types.js";
6
+ import {
7
+ NAV_ACCOUNT_OPEN_KEY,
8
+ NAV_FOLDER_OPEN_KEY,
9
+ NavSidebar,
10
+ readOpen,
11
+ writeOpen,
12
+ } from "./nav-sidebar.js";
13
+
14
+ const accounts: NavAccount[] = [
15
+ {
16
+ id: "acct-personal",
17
+ label: "Personal",
18
+ email: "matthijs@example.com",
19
+ mailboxes: [
20
+ { id: "personal-inbox", name: "Inbox", role: "inbox", unseen: 12 },
21
+ { id: "personal-sent", name: "Sent", role: "sent" },
22
+ { id: "personal-trash", name: "Trash", role: "trash" },
23
+ { id: "personal-travel", name: "Travel", fullPath: "Personal/Travel" },
24
+ ],
25
+ },
26
+ ];
27
+
28
+ /** Minimal in-memory localStorage stub for the persistence contract tests. */
29
+ class MemoryStorage {
30
+ private store = new Map<string, string>();
31
+ getItem(key: string): string | null {
32
+ return this.store.has(key) ? (this.store.get(key) ?? null) : null;
33
+ }
34
+ setItem(key: string, value: string): void {
35
+ this.store.set(key, value);
36
+ }
37
+ removeItem(key: string): void {
38
+ this.store.delete(key);
39
+ }
40
+ clear(): void {
41
+ this.store.clear();
42
+ }
43
+ }
44
+
45
+ /** An anchor-rendering linkComponent so we can assert real <a href> output. */
46
+ const hrefLink: NavLinkComponent = ({
47
+ navId,
48
+ className,
49
+ ariaLabel,
50
+ title,
51
+ children,
52
+ }) =>
53
+ createElement(
54
+ "a",
55
+ { href: `/mail/${navId}`, className, "aria-label": ariaLabel, title },
56
+ children,
57
+ );
58
+
59
+ describe("NavSidebar", () => {
60
+ it("renders the daily brief item", () => {
61
+ const html = renderToString(
62
+ createElement(NavSidebar, {
63
+ accounts,
64
+ selectedNavId: "personal-inbox",
65
+ briefUnseen: 7,
66
+ onSelectNav: () => undefined,
67
+ }),
68
+ );
69
+ assert.match(html, /Daily brief/);
70
+ });
71
+
72
+ it("renders system and custom mailbox names", () => {
73
+ const html = renderToString(
74
+ createElement(NavSidebar, {
75
+ accounts,
76
+ selectedNavId: "personal-inbox",
77
+ onSelectNav: () => undefined,
78
+ }),
79
+ );
80
+ assert.match(html, /Inbox/);
81
+ assert.match(html, /Sent/);
82
+ assert.match(html, /Trash/);
83
+ assert.match(html, /Travel/);
84
+ });
85
+
86
+ it("gives the selected item its active accent styling", () => {
87
+ const html = renderToString(
88
+ createElement(NavSidebar, {
89
+ accounts,
90
+ selectedNavId: "personal-inbox",
91
+ onSelectNav: () => undefined,
92
+ }),
93
+ );
94
+ assert.match(html, /bg-accent-2-soft/);
95
+ assert.match(html, /text-accent-2/);
96
+ });
97
+
98
+ it("renders navigation entries as anchors via linkComponent", () => {
99
+ const html = renderToString(
100
+ createElement(NavSidebar, {
101
+ accounts,
102
+ selectedNavId: "personal-inbox",
103
+ onSelectNav: () => undefined,
104
+ linkComponent: hrefLink,
105
+ }),
106
+ );
107
+ // Brief, system mailbox, and folder all become real <a href> links.
108
+ assert.match(html, /<a href="\/mail\/brief"/);
109
+ assert.match(html, /<a href="\/mail\/personal-inbox"/);
110
+ assert.match(html, /<a href="\/mail\/personal-travel"/);
111
+ });
112
+
113
+ it("carries aria-label and the fullPath title on a mailbox link", () => {
114
+ const html = renderToString(
115
+ createElement(NavSidebar, {
116
+ accounts,
117
+ selectedNavId: "personal-inbox",
118
+ onSelectNav: () => undefined,
119
+ linkComponent: hrefLink,
120
+ }),
121
+ );
122
+ assert.match(html, /aria-label="Inbox"/);
123
+ assert.match(html, /title="Personal\/Travel"/);
124
+ });
125
+
126
+ it("renders an Outbox entry with its pending count when set", () => {
127
+ const html = renderToString(
128
+ createElement(NavSidebar, {
129
+ accounts: [{ ...accounts[0], outboxPending: 3 }],
130
+ selectedNavId: "outbox",
131
+ onSelectNav: () => undefined,
132
+ linkComponent: hrefLink,
133
+ }),
134
+ );
135
+ assert.match(html, /Outbox/);
136
+ assert.match(html, /<a href="\/mail\/outbox"/);
137
+ assert.match(html, /3/);
138
+ });
139
+
140
+ it("omits the Outbox entry when outboxPending is undefined", () => {
141
+ const html = renderToString(
142
+ createElement(NavSidebar, {
143
+ accounts,
144
+ selectedNavId: "personal-inbox",
145
+ onSelectNav: () => undefined,
146
+ }),
147
+ );
148
+ assert.doesNotMatch(html, /Outbox/);
149
+ });
150
+
151
+ it("shows the empty state when no accounts are configured", () => {
152
+ const html = renderToString(
153
+ createElement(NavSidebar, {
154
+ accounts: [],
155
+ selectedNavId: "brief",
156
+ onSelectNav: () => undefined,
157
+ }),
158
+ );
159
+ assert.match(html, /No accounts configured/);
160
+ });
161
+
162
+ it("shows a per-account loading placeholder", () => {
163
+ const html = renderToString(
164
+ createElement(NavSidebar, {
165
+ accounts: [{ ...accounts[0], status: "loading", mailboxes: [] }],
166
+ selectedNavId: "brief",
167
+ onSelectNav: () => undefined,
168
+ }),
169
+ );
170
+ assert.match(html, /Loading/);
171
+ });
172
+
173
+ it("shows a per-account error with a retry affordance", () => {
174
+ const html = renderToString(
175
+ createElement(NavSidebar, {
176
+ accounts: [
177
+ {
178
+ ...accounts[0],
179
+ status: "error",
180
+ onRetry: () => undefined,
181
+ mailboxes: [],
182
+ },
183
+ ],
184
+ selectedNavId: "brief",
185
+ onSelectNav: () => undefined,
186
+ }),
187
+ );
188
+ // renderToString HTML-escapes the apostrophe in "Couldn't".
189
+ assert.match(html, /Couldn(?:'|&#x27;)t load mailboxes/);
190
+ assert.match(html, /Retry/);
191
+ });
192
+
193
+ it("shows the no-mailboxes copy for a ready account with none", () => {
194
+ const html = renderToString(
195
+ createElement(NavSidebar, {
196
+ accounts: [{ ...accounts[0], status: "ready", mailboxes: [] }],
197
+ selectedNavId: "brief",
198
+ onSelectNav: () => undefined,
199
+ }),
200
+ );
201
+ assert.match(html, /No mailboxes/);
202
+ });
203
+
204
+ // A Hostnet-shaped account: the adapter has already resolved roles and
205
+ // localized labels for namespace-nested, mostly-unflagged folders. The kit
206
+ // must pin Inbox/Drafts/Sent/Archive/Spam/Trash with their role icons and
207
+ // drop the custom folder into the collapsible Folders section.
208
+ const hostnet: NavAccount = {
209
+ id: "acct-hostnet",
210
+ label: "Hostnet",
211
+ email: "440737+mvhenten@users.noreply.github.com",
212
+ mailboxes: [
213
+ { id: "hn-inbox", name: "Inbox", role: "inbox", fullPath: "INBOX" },
214
+ { id: "hn-spam", name: "Spam", role: "junk", fullPath: "INBOX/Spam" },
215
+ { id: "hn-sent", name: "Sent", role: "sent", fullPath: "INBOX/Sent" },
216
+ {
217
+ id: "hn-drafts",
218
+ name: "Drafts",
219
+ role: "drafts",
220
+ fullPath: "INBOX/Drafts",
221
+ },
222
+ {
223
+ id: "hn-archive",
224
+ name: "Archive",
225
+ role: "archive",
226
+ fullPath: "INBOX/Archive",
227
+ },
228
+ {
229
+ id: "hn-trash",
230
+ name: "Trash",
231
+ role: "trash",
232
+ fullPath: "INBOX/Deleted Messages",
233
+ },
234
+ {
235
+ id: "hn-news",
236
+ name: "Nieuwsbrieven",
237
+ fullPath: "INBOX/Nieuwsbrieven",
238
+ },
239
+ ],
240
+ };
241
+
242
+ it("pins Hostnet system folders with their role icons", () => {
243
+ const html = renderToString(
244
+ createElement(NavSidebar, {
245
+ accounts: [hostnet],
246
+ selectedNavId: "hn-inbox",
247
+ onSelectNav: () => undefined,
248
+ }),
249
+ );
250
+ assert.match(html, /lucide-inbox/);
251
+ assert.match(html, /lucide-file-text/);
252
+ assert.match(html, /lucide-send/);
253
+ assert.match(html, /lucide-archive/);
254
+ assert.match(html, /lucide-octagon-alert/);
255
+ assert.match(html, /lucide-trash2/);
256
+ // The real user folder lands in the collapsible Folders section.
257
+ assert.match(html, /Nieuwsbrieven/);
258
+ assert.match(html, /Folders/);
259
+ });
260
+
261
+ it("orders Hostnet system folders canonically and before custom folders", () => {
262
+ const html = renderToString(
263
+ createElement(NavSidebar, {
264
+ accounts: [hostnet],
265
+ selectedNavId: "hn-inbox",
266
+ onSelectNav: () => undefined,
267
+ }),
268
+ );
269
+ const order = ["Inbox", "Drafts", "Sent", "Archive", "Spam", "Trash"].map(
270
+ (name) => html.indexOf(`>${name}<`),
271
+ );
272
+ for (const idx of order) assert.ok(idx >= 0);
273
+ for (let i = 1; i < order.length; i++) {
274
+ assert.ok(
275
+ order[i - 1] < order[i],
276
+ `Expected ${order.join(", ")} to be ascending`,
277
+ );
278
+ }
279
+ // Custom folder renders after every system folder.
280
+ assert.ok(html.indexOf(">Nieuwsbrieven<") > order[order.length - 1]);
281
+ });
282
+ });
283
+
284
+ describe("NavSidebar saved searches", () => {
285
+ it("omits the group when there are no saved searches and no active query", () => {
286
+ const html = renderToString(
287
+ createElement(NavSidebar, {
288
+ accounts,
289
+ selectedNavId: "brief",
290
+ onSelectNav: () => undefined,
291
+ }),
292
+ );
293
+ assert.doesNotMatch(html, /Saved searches/);
294
+ });
295
+
296
+ it("renders every saved search", () => {
297
+ const html = renderToString(
298
+ createElement(NavSidebar, {
299
+ accounts,
300
+ selectedNavId: "brief",
301
+ onSelectNav: () => undefined,
302
+ savedSearches: ["from:alice", "has:attachment invoice"],
303
+ }),
304
+ );
305
+ assert.match(html, /Saved searches/);
306
+ assert.match(html, /from:alice/);
307
+ assert.match(html, /has:attachment invoice/);
308
+ });
309
+
310
+ it("shows a save row for an active, unsaved query", () => {
311
+ const html = renderToString(
312
+ createElement(NavSidebar, {
313
+ accounts,
314
+ selectedNavId: "brief",
315
+ onSelectNav: () => undefined,
316
+ saveableQuery: "from:dhl",
317
+ }),
318
+ );
319
+ assert.match(html, /Saved searches/);
320
+ assert.match(html, /Save/);
321
+ assert.match(html, /from:dhl/);
322
+ });
323
+ });
324
+
325
+ describe("NavSidebar collapse persistence", () => {
326
+ afterEach(() => {
327
+ Reflect.deleteProperty(globalThis, "localStorage");
328
+ });
329
+
330
+ it("defaults to open when nothing is stored", () => {
331
+ (globalThis as { localStorage?: Storage }).localStorage =
332
+ new MemoryStorage() as unknown as Storage;
333
+ assert.equal(readOpen(NAV_FOLDER_OPEN_KEY, "acct-1", true), true);
334
+ assert.equal(readOpen(NAV_ACCOUNT_OPEN_KEY, "acct-1", true), true);
335
+ });
336
+
337
+ it("round-trips a stored collapsed flag per id", () => {
338
+ (globalThis as { localStorage?: Storage }).localStorage =
339
+ new MemoryStorage() as unknown as Storage;
340
+ writeOpen(NAV_ACCOUNT_OPEN_KEY, "acct-1", false);
341
+ assert.equal(readOpen(NAV_ACCOUNT_OPEN_KEY, "acct-1", true), false);
342
+ // A different id is unaffected — collapse is keyed per account.
343
+ assert.equal(readOpen(NAV_ACCOUNT_OPEN_KEY, "acct-2", true), true);
344
+ });
345
+
346
+ it("honours the supplied fallback with no storage available", () => {
347
+ // No localStorage on globalThis → return fallback verbatim.
348
+ assert.equal(readOpen(NAV_FOLDER_OPEN_KEY, "acct-1", false), false);
349
+ assert.equal(readOpen(NAV_FOLDER_OPEN_KEY, "acct-1", true), true);
350
+ });
351
+ });
@@ -0,0 +1,216 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import type { NavAccount } from "./app-shell-types.js";
3
+ import { NavSidebar } from "./nav-sidebar.js";
4
+
5
+ const accounts: NavAccount[] = [
6
+ {
7
+ id: "acct-personal",
8
+ label: "Personal",
9
+ email: "matthijs@example.com",
10
+ outboxPending: 2,
11
+ mailboxes: [
12
+ { id: "personal-inbox", name: "Inbox", role: "inbox", unseen: 12 },
13
+ { id: "personal-sent", name: "Sent", role: "sent" },
14
+ { id: "personal-archive", name: "Archive", role: "archive" },
15
+ { id: "personal-trash", name: "Trash", role: "trash" },
16
+ { id: "personal-receipts", name: "Receipts", unseen: 3 },
17
+ { id: "personal-travel", name: "Travel" },
18
+ ],
19
+ },
20
+ {
21
+ id: "acct-work",
22
+ label: "Work",
23
+ email: "matthijs@work.example",
24
+ outboxPending: 0,
25
+ mailboxes: [
26
+ { id: "work-inbox", name: "Inbox", role: "inbox", unseen: 4 },
27
+ { id: "work-sent", name: "Sent", role: "sent" },
28
+ { id: "work-trash", name: "Trash", role: "trash" },
29
+ ],
30
+ },
31
+ ];
32
+
33
+ const manyFoldersAccount: NavAccount = {
34
+ id: "acct-archivist",
35
+ label: "Archivist",
36
+ email: "archivist@example.com",
37
+ mailboxes: [
38
+ { id: "arch-inbox", name: "Inbox", role: "inbox", unseen: 1 },
39
+ { id: "arch-sent", name: "Sent", role: "sent" },
40
+ { id: "arch-trash", name: "Trash", role: "trash" },
41
+ { id: "arch-clients", name: "Clients" },
42
+ { id: "arch-invoices", name: "Invoices" },
43
+ { id: "arch-projects", name: "Projects" },
44
+ { id: "arch-newsletters", name: "Newsletters" },
45
+ { id: "arch-receipts", name: "Receipts" },
46
+ { id: "arch-travel", name: "Travel" },
47
+ { id: "arch-legal", name: "Legal" },
48
+ { id: "arch-taxes", name: "Taxes" },
49
+ { id: "arch-misc", name: "Misc" },
50
+ { id: "arch-2019", name: "2019" },
51
+ { id: "arch-2020", name: "2020" },
52
+ ],
53
+ };
54
+
55
+ const meta: Meta<typeof NavSidebar> = {
56
+ title: "Screens/Kit/NavSidebar",
57
+ component: NavSidebar,
58
+ parameters: { layout: "fullscreen" },
59
+ args: {
60
+ accounts,
61
+ briefUnseen: 7,
62
+ onSelectNav: () => undefined,
63
+ },
64
+ render: (args) => (
65
+ <div className="h-screen w-64 border-r border-line">
66
+ <NavSidebar {...args} />
67
+ </div>
68
+ ),
69
+ };
70
+ export default meta;
71
+
72
+ type Story = StoryObj<typeof NavSidebar>;
73
+
74
+ export const Default: Story = { args: { selectedNavId: "personal-inbox" } };
75
+
76
+ export const Brief: Story = { args: { selectedNavId: "brief" } };
77
+
78
+ export const Flagged: Story = { args: { selectedNavId: "flagged" } };
79
+
80
+ export const ManyFolders: Story = {
81
+ args: {
82
+ accounts: [manyFoldersAccount],
83
+ selectedNavId: "arch-inbox",
84
+ },
85
+ };
86
+
87
+ /**
88
+ * A Hostnet-shaped account: every folder is nested under the "INBOX/" personal
89
+ * namespace and only Spam carries a SPECIAL-USE flag, yet the adapter resolves
90
+ * roles + canonical labels so the kit still pins Inbox/Drafts/Sent/Archive/
91
+ * Spam/Trash and shows the real "Nieuwsbrieven" user folder under Folders.
92
+ */
93
+ const hostnetAccount: NavAccount = {
94
+ id: "acct-hostnet",
95
+ label: "Hostnet",
96
+ email: "440737+mvhenten@users.noreply.github.com",
97
+ mailboxes: [
98
+ {
99
+ id: "hn-inbox",
100
+ name: "Inbox",
101
+ role: "inbox",
102
+ fullPath: "INBOX",
103
+ unseen: 8,
104
+ },
105
+ {
106
+ id: "hn-drafts",
107
+ name: "Drafts",
108
+ role: "drafts",
109
+ fullPath: "INBOX/Drafts",
110
+ },
111
+ { id: "hn-sent", name: "Sent", role: "sent", fullPath: "INBOX/Sent" },
112
+ {
113
+ id: "hn-archive",
114
+ name: "Archive",
115
+ role: "archive",
116
+ fullPath: "INBOX/Archive",
117
+ },
118
+ {
119
+ id: "hn-spam",
120
+ name: "Spam",
121
+ role: "junk",
122
+ fullPath: "INBOX/Spam",
123
+ unseen: 3,
124
+ },
125
+ {
126
+ id: "hn-trash",
127
+ name: "Trash",
128
+ role: "trash",
129
+ fullPath: "INBOX/Deleted Messages",
130
+ },
131
+ {
132
+ id: "hn-news",
133
+ name: "Nieuwsbrieven",
134
+ fullPath: "INBOX/Nieuwsbrieven",
135
+ unseen: 2,
136
+ },
137
+ ],
138
+ };
139
+
140
+ export const Hostnet: Story = {
141
+ args: {
142
+ accounts: [hostnetAccount],
143
+ selectedNavId: "hn-inbox",
144
+ },
145
+ };
146
+
147
+ export const WithOutbox: Story = {
148
+ args: { selectedNavId: "outbox" },
149
+ };
150
+
151
+ export const NoAccounts: Story = {
152
+ args: { accounts: [], selectedNavId: "brief" },
153
+ };
154
+
155
+ export const Loading: Story = {
156
+ args: {
157
+ accounts: [
158
+ {
159
+ id: "acct-personal",
160
+ label: "Personal",
161
+ email: "matthijs@example.com",
162
+ status: "loading",
163
+ mailboxes: [],
164
+ },
165
+ ],
166
+ selectedNavId: "brief",
167
+ },
168
+ };
169
+
170
+ export const LoadError: Story = {
171
+ args: {
172
+ accounts: [
173
+ {
174
+ id: "acct-personal",
175
+ label: "Personal",
176
+ email: "matthijs@example.com",
177
+ status: "error",
178
+ onRetry: () => undefined,
179
+ mailboxes: [],
180
+ },
181
+ ],
182
+ selectedNavId: "brief",
183
+ },
184
+ };
185
+
186
+ /**
187
+ * The reserved "Saved searches" group (#428 follow-up): saved queries plus a
188
+ * "Save «query»" row when the active search isn't saved yet.
189
+ */
190
+ export const WithSavedSearches: Story = {
191
+ args: {
192
+ selectedNavId: "personal-inbox",
193
+ savedSearches: ["from:alice has:attachment", "account:work is:unread"],
194
+ saveableQuery: "in:archive invoice",
195
+ onSelectSavedSearch: () => undefined,
196
+ onRemoveSavedSearch: () => undefined,
197
+ onSaveCurrentSearch: () => undefined,
198
+ },
199
+ };
200
+
201
+ /** Each nav row is a real anchor: the linkComponent renders <a href>. */
202
+ export const AsLinks: Story = {
203
+ args: {
204
+ selectedNavId: "personal-inbox",
205
+ linkComponent: ({ navId, className, ariaLabel, title, children }) => (
206
+ <a
207
+ href={`#/${navId}`}
208
+ className={className}
209
+ aria-label={ariaLabel}
210
+ title={title}
211
+ >
212
+ {children}
213
+ </a>
214
+ ),
215
+ },
216
+ };