@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,100 @@
1
+ import { X } from "lucide-react";
2
+ import { Button } from "./button.js";
3
+ import { FilterSheet, type FilterSheetProps } from "./filter-sheet.js";
4
+ import { SearchBar } from "./search-bar.js";
5
+ import type { SearchResult } from "./search-result-row.js";
6
+ import { type SearchResultSection, SearchResults } from "./search-results.js";
7
+
8
+ export interface MobileSearchViewProps {
9
+ value: string;
10
+ onChange: (value: string) => void;
11
+ /** Query-only clear (Esc key). The visible X uses {@link onCancel}. */
12
+ onClear: () => void;
13
+ /**
14
+ * The single dismiss control (X): clears the query AND closes the takeover so
15
+ * the view returns to the plain list with no stranded query/URL state.
16
+ */
17
+ onCancel: () => void;
18
+ /**
19
+ * The shared FilterSheet config (categories + Unread/Flagged/attachment, plus
20
+ * the brief-only account source row). Feed it `briefFilterConfig(accounts)` or
21
+ * `inboxFilterConfig()` from `filter-presets`. Omit to drop the filter chrome.
22
+ */
23
+ filter?: Omit<FilterSheetProps, "children">;
24
+ /** Recent searches shown when the query is empty. */
25
+ recentSearches?: string[];
26
+ onPickRecent?: (query: string) => void;
27
+ /** Grouped result sections (e.g. "Top matches", "Related"). */
28
+ sections?: SearchResultSection[];
29
+ loading?: boolean;
30
+ onSelectResult?: (result: SearchResult) => void;
31
+ /** Active filter-token chips parsed from the query; see `SearchResultsProps`. */
32
+ tokens?: { label: string; onRemove: () => void }[];
33
+ }
34
+
35
+ /**
36
+ * The full-screen mobile search takeover. Mirrors `MobileReadingPane` chrome: a
37
+ * fixed top bar with the shared `SearchBar` and a single X that both clears the
38
+ * query and dismisses the takeover (the bar's own inline clear is suppressed so
39
+ * there is exactly one X). Below the bar the shared `SearchResults` body rides
40
+ * inside the shared `FilterSheet` (the
41
+ * same categories, Unread/Flagged/attachment toggles, and brief-only account row
42
+ * the inboxes use) so search carries identical filters; pass no `filter` to drop
43
+ * the chrome. Desktop reuses the same `SearchResults` body in the list pane.
44
+ * Presentational and prop-driven.
45
+ */
46
+ export function MobileSearchView({
47
+ value,
48
+ onChange,
49
+ onClear,
50
+ onCancel,
51
+ filter,
52
+ recentSearches,
53
+ onPickRecent,
54
+ sections,
55
+ loading,
56
+ onSelectResult,
57
+ tokens,
58
+ }: MobileSearchViewProps) {
59
+ const body = (
60
+ <SearchResults
61
+ value={value}
62
+ recentSearches={recentSearches}
63
+ onPickRecent={onPickRecent}
64
+ sections={sections}
65
+ loading={loading}
66
+ onSelectResult={onSelectResult}
67
+ tokens={tokens}
68
+ />
69
+ );
70
+
71
+ return (
72
+ <article className="flex h-full w-full min-w-0 flex-col bg-canvas">
73
+ <header className="flex h-pane-header shrink-0 items-center gap-2 border-b border-line bg-surface px-row-inset">
74
+ <div className="min-w-0 flex-1">
75
+ <SearchBar
76
+ value={value}
77
+ onChange={onChange}
78
+ onClear={onClear}
79
+ globalFocusKey={false}
80
+ showClearButton={false}
81
+ />
82
+ </div>
83
+ <Button
84
+ variant="ghost"
85
+ size="sm"
86
+ icon={<X className="size-4" />}
87
+ onClick={onCancel}
88
+ aria-label="Clear and close search"
89
+ className="-mr-1 shrink-0"
90
+ />
91
+ </header>
92
+
93
+ {filter ? (
94
+ <FilterSheet {...filter}>{body}</FilterSheet>
95
+ ) : (
96
+ <div className="flex-1 overflow-y-auto">{body}</div>
97
+ )}
98
+ </article>
99
+ );
100
+ }
@@ -0,0 +1,113 @@
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 {
6
+ type MoveMailboxOption,
7
+ MoveMailboxPicker,
8
+ moveMailboxPickerInternals,
9
+ } from "./move-mailbox-picker.js";
10
+
11
+ const {
12
+ findFirstSelectable,
13
+ findLastSelectable,
14
+ findNextSelectable,
15
+ matchesQuery,
16
+ } = moveMailboxPickerInternals;
17
+
18
+ const options: MoveMailboxOption[] = [
19
+ { id: "inbox", label: "Inbox", isCurrent: true },
20
+ { id: "archive", label: "Archive" },
21
+ { id: "trash", label: "Trash" },
22
+ ];
23
+
24
+ describe("roving-focus selection", () => {
25
+ it("skips the current folder when finding the first selectable", () => {
26
+ assert.equal(findFirstSelectable(options), 1);
27
+ });
28
+
29
+ it("finds the last selectable", () => {
30
+ assert.equal(findLastSelectable(options), 2);
31
+ });
32
+
33
+ it("wraps forward over the non-selectable current folder", () => {
34
+ assert.equal(findNextSelectable(options, 2, 1), 1);
35
+ });
36
+
37
+ it("wraps backward over the non-selectable current folder", () => {
38
+ assert.equal(findNextSelectable(options, 1, -1), 2);
39
+ });
40
+
41
+ it("returns -1 when every option is the current folder", () => {
42
+ const allCurrent: MoveMailboxOption[] = [
43
+ { id: "a", label: "A", isCurrent: true },
44
+ ];
45
+ assert.equal(findFirstSelectable(allCurrent), -1);
46
+ assert.equal(findNextSelectable(allCurrent, 0, 1), -1);
47
+ });
48
+ });
49
+
50
+ describe("query matching", () => {
51
+ it("matches on the display label, case-insensitively", () => {
52
+ assert.equal(matchesQuery({ id: "a", label: "Archive" }, "arch"), true);
53
+ });
54
+
55
+ it("matches on the hidden searchValue (nested path)", () => {
56
+ assert.equal(
57
+ matchesQuery(
58
+ { id: "r", label: "Receipts", searchValue: "finance/receipts" },
59
+ "finance/",
60
+ ),
61
+ true,
62
+ );
63
+ });
64
+
65
+ it("does not match unrelated text", () => {
66
+ assert.equal(matchesQuery({ id: "a", label: "Archive" }, "spam"), false);
67
+ });
68
+ });
69
+
70
+ describe("MoveMailboxPicker render", () => {
71
+ it("renders each mailbox as a listbox option", () => {
72
+ const html = renderToString(
73
+ createElement(MoveMailboxPicker, {
74
+ mailboxes: options,
75
+ onSelect: () => {},
76
+ }),
77
+ );
78
+ assert.match(html, /role="listbox"/);
79
+ const optionCount = html.match(/role="option"/g)?.length ?? 0;
80
+ assert.equal(optionCount, 3);
81
+ });
82
+
83
+ it("marks the current folder as a non-interactive marker, never a disabled button", () => {
84
+ const html = renderToString(
85
+ createElement(MoveMailboxPicker, {
86
+ mailboxes: options,
87
+ onSelect: () => {},
88
+ }),
89
+ );
90
+ assert.match(html, /aria-label="Inbox \(current folder\)"/);
91
+ assert.match(html, /aria-current="true"/);
92
+ assert.match(html, /aria-label="Move to Archive"/);
93
+ assert.doesNotMatch(html, /disabled/);
94
+ });
95
+
96
+ it("renders the empty state when there are no mailboxes", () => {
97
+ const html = renderToString(
98
+ createElement(MoveMailboxPicker, { mailboxes: [], onSelect: () => {} }),
99
+ );
100
+ assert.match(html, /No folders match/);
101
+ });
102
+
103
+ it("applies caller-supplied labels", () => {
104
+ const html = renderToString(
105
+ createElement(MoveMailboxPicker, {
106
+ mailboxes: options,
107
+ onSelect: () => {},
108
+ labels: { optionLabel: (label) => `Verplaats naar ${label}` },
109
+ }),
110
+ );
111
+ assert.match(html, /aria-label="Verplaats naar Archive"/);
112
+ });
113
+ });
@@ -0,0 +1,76 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import { useState } from "react";
3
+ import {
4
+ type MoveMailboxOption,
5
+ MoveMailboxPicker,
6
+ } from "./move-mailbox-picker.js";
7
+
8
+ const mailboxes: MoveMailboxOption[] = [
9
+ { id: "inbox", label: "Inbox", isCurrent: true },
10
+ { id: "archive", label: "Archive" },
11
+ { id: "trash", label: "Trash" },
12
+ { id: "spam", label: "Spam" },
13
+ { id: "receipts", label: "Receipts", searchValue: "finance/receipts" },
14
+ { id: "travel", label: "Travel", searchValue: "finance/travel" },
15
+ { id: "newsletters", label: "Newsletters" },
16
+ ];
17
+
18
+ const manyMailboxes: MoveMailboxOption[] = [
19
+ { id: "inbox", label: "Inbox", isCurrent: true },
20
+ ...Array.from({ length: 24 }, (_, i) => ({
21
+ id: `folder-${i}`,
22
+ label: `Project ${String(i + 1).padStart(2, "0")}`,
23
+ })),
24
+ ];
25
+
26
+ const meta: Meta<typeof MoveMailboxPicker> = {
27
+ title: "Mail/MoveMailboxPicker",
28
+ component: MoveMailboxPicker,
29
+ parameters: { layout: "centered" },
30
+ decorators: [
31
+ (Story) => (
32
+ <div className="w-72 max-h-96 overflow-hidden rounded-md border border-line bg-surface shadow-lg">
33
+ <Story />
34
+ </div>
35
+ ),
36
+ ],
37
+ };
38
+ export default meta;
39
+
40
+ type Story = StoryObj<typeof MoveMailboxPicker>;
41
+
42
+ const Picker = ({ options }: { options: MoveMailboxOption[] }) => {
43
+ const [moved, setMoved] = useState<string | null>(null);
44
+ return (
45
+ <div className="flex flex-col">
46
+ <MoveMailboxPicker mailboxes={options} onSelect={setMoved} />
47
+ {moved && (
48
+ <p className="border-t border-line px-3 py-2 text-xs text-fg-muted">
49
+ Moved to {moved}
50
+ </p>
51
+ )}
52
+ </div>
53
+ );
54
+ };
55
+
56
+ export const Default: Story = {
57
+ name: "Default (current folder marked)",
58
+ render: () => <Picker options={mailboxes} />,
59
+ };
60
+
61
+ export const ManyMailboxes: Story = {
62
+ name: "Many mailboxes (scrolls)",
63
+ render: () => <Picker options={manyMailboxes} />,
64
+ };
65
+
66
+ export const Empty: Story = {
67
+ name: "Empty list",
68
+ render: () => <Picker options={[]} />,
69
+ };
70
+
71
+ export const Autofocus: Story = {
72
+ name: "Autofocus search (mobile sheet)",
73
+ render: () => (
74
+ <MoveMailboxPicker mailboxes={mailboxes} onSelect={() => {}} autoFocus />
75
+ ),
76
+ };
@@ -0,0 +1,324 @@
1
+ import { Search } from "lucide-react";
2
+ import {
3
+ type KeyboardEvent as ReactKeyboardEvent,
4
+ useCallback,
5
+ useEffect,
6
+ useMemo,
7
+ useRef,
8
+ useState,
9
+ } from "react";
10
+ import { cn } from "../lib/cn.js";
11
+ import { Input } from "./input.js";
12
+
13
+ export interface MoveMailboxOption {
14
+ /** Stable identity passed back to `onSelect`. */
15
+ id: string;
16
+ /** Display label shown in the row and matched against the search query. */
17
+ label: string;
18
+ /**
19
+ * The message's current folder. Rendered as a non-selectable row so the
20
+ * user sees where the message lives but can't move it onto itself.
21
+ */
22
+ isCurrent?: boolean;
23
+ /**
24
+ * Optional secondary string matched alongside `label` (e.g. the full
25
+ * folder path so "gmail/" narrows nested folders). Never displayed.
26
+ */
27
+ searchValue?: string;
28
+ }
29
+
30
+ export interface MoveMailboxPickerLabels {
31
+ /** Search input placeholder. */
32
+ searchPlaceholder?: string;
33
+ /** Accessible label for the search input. */
34
+ searchAriaLabel?: string;
35
+ /** Accessible label for the listbox. */
36
+ listAriaLabel?: string;
37
+ /** Suffix announced for the current folder, e.g. `(current folder)`. */
38
+ currentSuffix?: string;
39
+ /** Inline tag shown on the current folder row. */
40
+ currentTag?: string;
41
+ /** Builds the empty-state message for a query that matches nothing. */
42
+ emptyMessage?: (query: string) => string;
43
+ /** Builds the accessible label for a selectable row, e.g. `Move to X`. */
44
+ optionLabel?: (label: string) => string;
45
+ }
46
+
47
+ export interface MoveMailboxPickerProps {
48
+ /**
49
+ * Destinations to show, already filtered, ordered and labeled by the app.
50
+ * The kit owns only search, focus and keyboard — never data shaping.
51
+ */
52
+ mailboxes: readonly MoveMailboxOption[];
53
+ onSelect: (mailboxId: string) => void;
54
+ /**
55
+ * Called when the user dismisses the picker via Escape. Trigger consumers
56
+ * use this to close their popover/drawer; the picker never owns
57
+ * presentation, so it cannot close itself without help.
58
+ */
59
+ onCancel?: () => void;
60
+ /**
61
+ * Mobile callers (bottom-sheet) pass `autoFocus` to focus the search input
62
+ * as soon as the sheet opens — keyboard accessory + immediate filter typing
63
+ * without an extra tap. Desktop dropdowns leave focus on the trigger so
64
+ * click-outside dismissal stays predictable.
65
+ */
66
+ autoFocus?: boolean;
67
+ labels?: MoveMailboxPickerLabels;
68
+ }
69
+
70
+ const ROW_BASE =
71
+ "w-full text-left px-3 py-2.5 min-h-11 flex items-center gap-2 transition-colors text-sm rounded-md focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-canvas";
72
+
73
+ const defaultLabels: Required<MoveMailboxPickerLabels> = {
74
+ searchPlaceholder: "Move to…",
75
+ searchAriaLabel: "Filter folders",
76
+ listAriaLabel: "Destination folders",
77
+ currentSuffix: "(current folder)",
78
+ currentTag: "current",
79
+ emptyMessage: (query) => `No folders match "${query}"`,
80
+ optionLabel: (label) => `Move to ${label}`,
81
+ };
82
+
83
+ const findFirstSelectable = (options: readonly MoveMailboxOption[]): number => {
84
+ for (let i = 0; i < options.length; i += 1) {
85
+ if (!options[i]?.isCurrent) return i;
86
+ }
87
+ return -1;
88
+ };
89
+
90
+ const findLastSelectable = (options: readonly MoveMailboxOption[]): number => {
91
+ for (let i = options.length - 1; i >= 0; i -= 1) {
92
+ if (!options[i]?.isCurrent) return i;
93
+ }
94
+ return -1;
95
+ };
96
+
97
+ const findNextSelectable = (
98
+ options: readonly MoveMailboxOption[],
99
+ from: number,
100
+ step: 1 | -1,
101
+ ): number => {
102
+ const count = options.length;
103
+ if (count <= 0) return -1;
104
+ const start = from < 0 ? (step === 1 ? -1 : count) : from;
105
+ for (let offset = 1; offset <= count; offset += 1) {
106
+ const candidate = (((start + step * offset) % count) + count) % count;
107
+ if (!options[candidate]?.isCurrent) return candidate;
108
+ }
109
+ return -1;
110
+ };
111
+
112
+ const matchesQuery = (option: MoveMailboxOption, query: string): boolean => {
113
+ if (option.label.toLowerCase().includes(query)) return true;
114
+ return option.searchValue?.toLowerCase().includes(query) ?? false;
115
+ };
116
+
117
+ /**
118
+ * Move-to-folder destination picker: an always-on search input over a
119
+ * roving-focus listbox. Data-agnostic — the app supplies pre-shaped,
120
+ * pre-ordered options and performs the move in `onSelect`; the kit owns search
121
+ * filtering, keyboard navigation and ARIA structure.
122
+ */
123
+ export const MoveMailboxPicker = ({
124
+ mailboxes,
125
+ onSelect,
126
+ onCancel,
127
+ autoFocus = false,
128
+ labels,
129
+ }: MoveMailboxPickerProps) => {
130
+ const text = { ...defaultLabels, ...labels };
131
+ const [query, setQuery] = useState("");
132
+ const [focusedIndex, setFocusedIndex] = useState<number>(() =>
133
+ findFirstSelectable(mailboxes),
134
+ );
135
+ const inputRef = useRef<HTMLInputElement>(null);
136
+ const optionRefs = useRef<Array<HTMLButtonElement | null>>([]);
137
+
138
+ useEffect(() => {
139
+ if (autoFocus) inputRef.current?.focus();
140
+ }, [autoFocus]);
141
+
142
+ const trimmedQuery = query.trim().toLowerCase();
143
+ const filtered = useMemo(() => {
144
+ if (!trimmedQuery) return [...mailboxes];
145
+ return mailboxes.filter((mailbox) => matchesQuery(mailbox, trimmedQuery));
146
+ }, [mailboxes, trimmedQuery]);
147
+
148
+ useEffect(() => {
149
+ setFocusedIndex((current) => {
150
+ if (
151
+ current >= 0 &&
152
+ current < filtered.length &&
153
+ !filtered[current]?.isCurrent
154
+ ) {
155
+ return current;
156
+ }
157
+ return findFirstSelectable(filtered);
158
+ });
159
+ }, [filtered]);
160
+
161
+ useEffect(() => {
162
+ if (focusedIndex < 0) return;
163
+ const node = optionRefs.current[focusedIndex];
164
+ if (!node) return;
165
+ // Only steal focus if the user is already navigating the list — moving
166
+ // focus while they type in the filter input would trap them. The search
167
+ // input keeps focus until the first ArrowDown.
168
+ if (document.activeElement === inputRef.current) return;
169
+ node.focus();
170
+ }, [focusedIndex]);
171
+
172
+ const handleConfirm = useCallback(() => {
173
+ if (focusedIndex < 0) return;
174
+ const target = filtered[focusedIndex];
175
+ if (!target || target.isCurrent) return;
176
+ onSelect(target.id);
177
+ }, [focusedIndex, filtered, onSelect]);
178
+
179
+ const handleListKeyDown = useCallback(
180
+ (event: ReactKeyboardEvent<HTMLElement>) => {
181
+ switch (event.key) {
182
+ case "ArrowDown":
183
+ event.preventDefault();
184
+ setFocusedIndex((current) =>
185
+ findNextSelectable(filtered, current, 1),
186
+ );
187
+ return;
188
+ case "ArrowUp":
189
+ event.preventDefault();
190
+ setFocusedIndex((current) =>
191
+ findNextSelectable(filtered, current, -1),
192
+ );
193
+ return;
194
+ case "Home":
195
+ event.preventDefault();
196
+ setFocusedIndex(findFirstSelectable(filtered));
197
+ return;
198
+ case "End":
199
+ event.preventDefault();
200
+ setFocusedIndex(findLastSelectable(filtered));
201
+ return;
202
+ case "Enter":
203
+ case " ":
204
+ event.preventDefault();
205
+ handleConfirm();
206
+ return;
207
+ case "Escape":
208
+ event.preventDefault();
209
+ onCancel?.();
210
+ return;
211
+ default:
212
+ return;
213
+ }
214
+ },
215
+ [filtered, handleConfirm, onCancel],
216
+ );
217
+
218
+ const handleInputKeyDown = useCallback(
219
+ (event: ReactKeyboardEvent<HTMLInputElement>) => {
220
+ if (event.key === "ArrowDown") {
221
+ event.preventDefault();
222
+ if (filtered.length === 0) return;
223
+ const target = optionRefs.current[focusedIndex >= 0 ? focusedIndex : 0];
224
+ target?.focus();
225
+ return;
226
+ }
227
+ if (event.key === "Escape") {
228
+ event.preventDefault();
229
+ onCancel?.();
230
+ }
231
+ },
232
+ [filtered.length, focusedIndex, onCancel],
233
+ );
234
+
235
+ return (
236
+ <div className="flex flex-col">
237
+ <Input
238
+ variant="inline"
239
+ className="border-b border-line px-3 py-2"
240
+ icon={<Search className="size-4" aria-hidden="true" />}
241
+ ref={inputRef}
242
+ type="search"
243
+ value={query}
244
+ onChange={(event) => setQuery(event.target.value)}
245
+ onKeyDown={handleInputKeyDown}
246
+ placeholder={text.searchPlaceholder}
247
+ aria-label={text.searchAriaLabel}
248
+ />
249
+ <div
250
+ className="flex-1 overflow-y-auto py-1"
251
+ role="listbox"
252
+ aria-label={text.listAriaLabel}
253
+ onKeyDown={handleListKeyDown}
254
+ >
255
+ {filtered.length === 0 ? (
256
+ <div className="px-3 py-3 text-sm text-fg-muted" aria-live="polite">
257
+ {text.emptyMessage(query)}
258
+ </div>
259
+ ) : (
260
+ filtered.map((mailbox, idx) => {
261
+ const isCurrent = mailbox.isCurrent ?? false;
262
+ const isFocused = idx === focusedIndex;
263
+ // The current folder is a "you are here" marker, not a target —
264
+ // rendered as a static option (never a disabled control) so the
265
+ // user can see where the message lives without a dead button.
266
+ // Selectable folders are real <button> options. Each interactive
267
+ // element IS the listbox option: a role="option" <div> wrapping a
268
+ // separately-interactive <button> is invalid ARIA.
269
+ if (isCurrent) {
270
+ return (
271
+ <div key={mailbox.id}>
272
+ {/* biome-ignore lint/a11y/useFocusableInteractive: focus managed by the parent listbox component */}
273
+ <div
274
+ role="option"
275
+ aria-selected={false}
276
+ aria-current="true"
277
+ aria-label={`${mailbox.label} ${text.currentSuffix}`}
278
+ className={cn(ROW_BASE, "opacity-60 bg-surface-sunken/40")}
279
+ >
280
+ <span className="truncate flex-1">{mailbox.label}</span>
281
+ <span className="text-xs text-fg-muted shrink-0">
282
+ {text.currentTag}
283
+ </span>
284
+ </div>
285
+ </div>
286
+ );
287
+ }
288
+ return (
289
+ <div key={mailbox.id}>
290
+ <button
291
+ ref={(node) => {
292
+ optionRefs.current[idx] = node;
293
+ }}
294
+ type="button"
295
+ role="option"
296
+ aria-selected={false}
297
+ tabIndex={isFocused ? 0 : -1}
298
+ onClick={() => onSelect(mailbox.id)}
299
+ onFocus={() => setFocusedIndex(idx)}
300
+ aria-label={text.optionLabel(mailbox.label)}
301
+ className={cn(ROW_BASE, "hover:bg-surface-raised")}
302
+ >
303
+ <span className="truncate flex-1">{mailbox.label}</span>
304
+ </button>
305
+ </div>
306
+ );
307
+ })
308
+ )}
309
+ </div>
310
+ </div>
311
+ );
312
+ };
313
+
314
+ /**
315
+ * Pure selection/filter helpers exposed for unit testing the roving-focus and
316
+ * search logic without a DOM. Component consumers should use
317
+ * {@link MoveMailboxPicker} instead.
318
+ */
319
+ export const moveMailboxPickerInternals = {
320
+ findFirstSelectable,
321
+ findLastSelectable,
322
+ findNextSelectable,
323
+ matchesQuery,
324
+ };