@rapidmx/web-client 0.6.0 → 0.7.0

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 (79) hide show
  1. package/apps/shared/components/admin/settings/BrandingForm.tsx +398 -385
  2. package/apps/shared/components/admin/settings/EncryptionPolicyForm.tsx +119 -112
  3. package/apps/shared/components/admin/settings/MailboxPolicyForm.tsx +159 -152
  4. package/apps/shared/components/admin/settings/PluginsManager.tsx +1288 -1235
  5. package/apps/shared/components/admin/settings/RetentionPolicyForm.tsx +9 -1
  6. package/apps/shared/components/admin/setup/EscrowSetupStep.tsx +288 -290
  7. package/apps/shared/components/admin/setup/SetupWizard.tsx +443 -416
  8. package/apps/shared/components/layout/AppShell.tsx +288 -288
  9. package/apps/shared/components/mail/ConversationList.tsx +225 -33
  10. package/apps/shared/components/mail/ConversationThreadPane.tsx +434 -216
  11. package/apps/shared/components/mail/MailListToolbar.tsx +265 -0
  12. package/apps/shared/components/mail/MailSelectionBar.tsx +222 -0
  13. package/apps/shared/components/mail/MenuButton.tsx +384 -0
  14. package/apps/shared/components/mail/MessageDetailPane.tsx +280 -215
  15. package/apps/shared/components/mail/MoveToFolderDialog.tsx +263 -0
  16. package/apps/shared/components/mail/compose/ComposeContext.tsx +29 -2
  17. package/apps/shared/components/mail/compose/ComposeWindow.tsx +1700 -1652
  18. package/apps/shared/components/mail/compose/RecipientInput.tsx +356 -0
  19. package/apps/shared/components/mail/compose/RichTextEditor.tsx +38 -3
  20. package/apps/shared/components/mail/compose/quotedBody.ts +100 -0
  21. package/apps/shared/components/mail/compose/recipients.ts +104 -0
  22. package/apps/shared/components/mail/labelMenu.tsx +278 -0
  23. package/apps/shared/components/mail/layout/MailShell.tsx +445 -420
  24. package/apps/shared/components/mail/listPreferences.ts +228 -0
  25. package/apps/shared/components/settings/layout/SettingsShell.tsx +0 -1
  26. package/apps/shared/styles/app.css +28 -5
  27. package/apps/www/index.tsx +1854 -1314
  28. package/apps/www/messages/[uid].tsx +5 -4
  29. package/dist/apps/shared/components/admin/settings/BrandingForm.d.ts +7 -3
  30. package/dist/apps/shared/components/admin/settings/BrandingForm.js +6 -5
  31. package/dist/apps/shared/components/admin/settings/EncryptionPolicyForm.d.ts +4 -1
  32. package/dist/apps/shared/components/admin/settings/EncryptionPolicyForm.js +2 -2
  33. package/dist/apps/shared/components/admin/settings/MailboxPolicyForm.d.ts +4 -1
  34. package/dist/apps/shared/components/admin/settings/MailboxPolicyForm.js +2 -2
  35. package/dist/apps/shared/components/admin/settings/PluginsManager.d.ts +8 -1
  36. package/dist/apps/shared/components/admin/settings/PluginsManager.js +33 -14
  37. package/dist/apps/shared/components/admin/settings/RetentionPolicyForm.d.ts +4 -1
  38. package/dist/apps/shared/components/admin/settings/RetentionPolicyForm.js +2 -2
  39. package/dist/apps/shared/components/admin/setup/EscrowSetupStep.js +1 -1
  40. package/dist/apps/shared/components/admin/setup/SetupWizard.js +14 -10
  41. package/dist/apps/shared/components/mail/ConversationList.d.ts +42 -6
  42. package/dist/apps/shared/components/mail/ConversationList.js +84 -10
  43. package/dist/apps/shared/components/mail/ConversationThreadPane.d.ts +52 -13
  44. package/dist/apps/shared/components/mail/ConversationThreadPane.js +217 -60
  45. package/dist/apps/shared/components/mail/MailListToolbar.d.ts +55 -0
  46. package/dist/apps/shared/components/mail/MailListToolbar.js +149 -0
  47. package/dist/apps/shared/components/mail/MailSelectionBar.d.ts +59 -0
  48. package/dist/apps/shared/components/mail/MailSelectionBar.js +48 -0
  49. package/dist/apps/shared/components/mail/MenuButton.d.ts +71 -0
  50. package/dist/apps/shared/components/mail/MenuButton.js +211 -0
  51. package/dist/apps/shared/components/mail/MessageDetailPane.d.ts +27 -19
  52. package/dist/apps/shared/components/mail/MessageDetailPane.js +126 -129
  53. package/dist/apps/shared/components/mail/MoveToFolderDialog.d.ts +57 -0
  54. package/dist/apps/shared/components/mail/MoveToFolderDialog.js +138 -0
  55. package/dist/apps/shared/components/mail/compose/ComposeContext.d.ts +16 -1
  56. package/dist/apps/shared/components/mail/compose/ComposeContext.js +3 -1
  57. package/dist/apps/shared/components/mail/compose/ComposeWindow.js +52 -20
  58. package/dist/apps/shared/components/mail/compose/RecipientInput.d.ts +36 -0
  59. package/dist/apps/shared/components/mail/compose/RecipientInput.js +203 -0
  60. package/dist/apps/shared/components/mail/compose/RichTextEditor.d.ts +10 -1
  61. package/dist/apps/shared/components/mail/compose/RichTextEditor.js +21 -3
  62. package/dist/apps/shared/components/mail/compose/quotedBody.d.ts +37 -0
  63. package/dist/apps/shared/components/mail/compose/quotedBody.js +85 -0
  64. package/dist/apps/shared/components/mail/compose/recipients.d.ts +24 -0
  65. package/dist/apps/shared/components/mail/compose/recipients.js +87 -0
  66. package/dist/apps/shared/components/mail/labelMenu.d.ts +97 -0
  67. package/dist/apps/shared/components/mail/labelMenu.js +134 -0
  68. package/dist/apps/shared/components/mail/layout/MailShell.d.ts +8 -0
  69. package/dist/apps/shared/components/mail/layout/MailShell.js +15 -3
  70. package/dist/apps/shared/components/mail/listPreferences.d.ts +101 -0
  71. package/dist/apps/shared/components/mail/listPreferences.js +186 -0
  72. package/dist/apps/shared/components/settings/layout/SettingsShell.js +0 -1
  73. package/dist/apps/shared/styles/app.css +28 -5
  74. package/dist/apps/www/index.js +506 -112
  75. package/dist/apps/www/messages/[uid].js +2 -3
  76. package/package.json +2 -2
  77. package/apps/www/settings/focused-inbox/index.tsx +0 -150
  78. package/dist/apps/www/settings/focused-inbox/index.d.ts +0 -4
  79. package/dist/apps/www/settings/focused-inbox/index.js +0 -71
@@ -0,0 +1,55 @@
1
+ import React from "react";
2
+ import { MessageListFilter, MessageListSort, MessageSortOrder } from "@rapidmx/react-shared/mail/mailApi.js";
3
+ import { Label } from "@rapidmx/react-shared/mail/labelsApi.js";
4
+ export interface MailListToolbarProps {
5
+ sortBy: MessageListSort;
6
+ sortOrder: MessageSortOrder;
7
+ filter: MessageListFilter;
8
+ /** The labels the list is currently narrowed to - any of them, not all. */
9
+ labelUids: string[];
10
+ /** Every label this mailbox has, for the Filter menu's own Labels submenu. */
11
+ labels: Label[];
12
+ onLabelUidsChange: (labelUids: string[]) => void;
13
+ /** The mailbox a label created from the Filter menu belongs to. */
14
+ mailboxUid: string;
15
+ /** Handed a label just created from the Filter menu, for the caller to add to `labels`. */
16
+ onLabelCreated: (label: Label) => void;
17
+ showAsConversations: boolean;
18
+ onSortChange: (sortBy: MessageListSort, sortOrder: MessageSortOrder) => void;
19
+ onFilterChange: (filter: MessageListFilter) => void;
20
+ onShowAsConversationsChange: (showAsConversations: boolean) => void;
21
+ selectMode: boolean;
22
+ onSelectModeChange: (selectMode: boolean) => void;
23
+ /** Offers the Focused/Other half of the filter vocabulary - an Inbox-only concept. */
24
+ offerClassificationFilters: boolean;
25
+ /** Search results are ranked by relevance across folders rather than listed from one, so the named
26
+ * filters don't apply to them. */
27
+ filterDisabled?: boolean;
28
+ filterDisabledReason?: string;
29
+ /** Greys out the sort keys and order while something else decides the order entirely: a search (ranked
30
+ * by relevance) or an aggregate view (one page merged from each mailbox). The Sort button itself stays
31
+ * open, since "Show as conversations" lives in it. */
32
+ sortKeysDisabled?: boolean;
33
+ /** Says why, under the sort keys. */
34
+ sortKeysNote?: string;
35
+ /** Individual sort keys the current arrangement can't order by, each with the reason shown beside it -
36
+ * the conversation list's own case, where the rows are thread summaries carrying no value for some of
37
+ * these (see `CONVERSATION_SORT_UNAVAILABLE`). The rest stay pickable. */
38
+ unavailableSortKeys?: Partial<Record<MessageListSort, string>>;
39
+ /** Select mode acts on the messages of one mailbox - so it is offered over both the message list and
40
+ * the conversation list (where a ticked row means every message of that conversation in this folder),
41
+ * but not over an aggregate view, whose rows come from several mailboxes whose folders a single Move
42
+ * couldn't name, nor over a list that is still loading or has no rows to tick. */
43
+ selectDisabled?: boolean;
44
+ selectDisabledReason?: string;
45
+ }
46
+ /**
47
+ * The mail list's Outlook-style toolbar: a Filter menu, a Sort menu (which also carries "Show as
48
+ * conversations", Outlook's own home for it - it arranges the list rather than acting on a message, and
49
+ * keeping it there leaves the toolbar three controls wide, which is what fits a 400px phone) and a Select
50
+ * toggle that turns the list into a multi-select with bulk actions.
51
+ *
52
+ * Both menus drive server-side parameters (`listMessages()`'s `sortBy`/`sortOrder`/`filter`), so they sort
53
+ * and filter the whole folder rather than the page that happens to be loaded.
54
+ */
55
+ export default function MailListToolbar({ sortBy, sortOrder, filter, labelUids, labels, onLabelUidsChange, mailboxUid, onLabelCreated, showAsConversations, onSortChange, onFilterChange, onShowAsConversationsChange, selectMode, onSelectModeChange, offerClassificationFilters, filterDisabled, filterDisabledReason, sortKeysDisabled, sortKeysNote, unavailableSortKeys, selectDisabled, selectDisabledReason, }: MailListToolbarProps): React.JSX.Element;
@@ -0,0 +1,149 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ ///////////////////////////////////////////////////////////////////////////////
3
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
4
+ // SPDX-License-Identifier: MPL-2.0
5
+ ///////////////////////////////////////////////////////////////////////////////
6
+ import { useState } from "react";
7
+ import { HiOutlineAdjustmentsHorizontal, HiOutlineBarsArrowDown, HiOutlineStop } from "react-icons/hi2";
8
+ import MenuButton from "./MenuButton.js";
9
+ import { NewLabelDialog, labelSections, useLabelDraft } from "./labelMenu.js";
10
+ import { MAIL_LIST_CLASSIFICATION_FILTERS, MAIL_LIST_FILTERS, MAIL_LIST_SORTS, SORT_ORDER_LABELS, defaultSortOrder, } from "./listPreferences.js";
11
+ /** The label for the filter currently in force, or `undefined` for "All" (which isn't worth naming on the
12
+ * button). */
13
+ function filterLabel(filter) {
14
+ return [...MAIL_LIST_FILTERS, ...MAIL_LIST_CLASSIFICATION_FILTERS].find((entry) => entry.value === filter && entry.value !== "all")
15
+ ?.label;
16
+ }
17
+ function sortLabel(sortBy) {
18
+ // Every `MessageListSort` has an entry, so this never falls through.
19
+ return MAIL_LIST_SORTS.find((entry) => entry.value === sortBy).label;
20
+ }
21
+ /**
22
+ * The mail list's Outlook-style toolbar: a Filter menu, a Sort menu (which also carries "Show as
23
+ * conversations", Outlook's own home for it - it arranges the list rather than acting on a message, and
24
+ * keeping it there leaves the toolbar three controls wide, which is what fits a 400px phone) and a Select
25
+ * toggle that turns the list into a multi-select with bulk actions.
26
+ *
27
+ * Both menus drive server-side parameters (`listMessages()`'s `sortBy`/`sortOrder`/`filter`), so they sort
28
+ * and filter the whole folder rather than the page that happens to be loaded.
29
+ */
30
+ export default function MailListToolbar({ sortBy, sortOrder, filter, labelUids, labels, onLabelUidsChange, mailboxUid, onLabelCreated, showAsConversations, onSortChange, onFilterChange, onShowAsConversationsChange, selectMode, onSelectModeChange, offerClassificationFilters, filterDisabled, filterDisabledReason, sortKeysDisabled, sortKeysNote, unavailableSortKeys, selectDisabled, selectDisabledReason, }) {
31
+ const activeFilter = filterLabel(filter);
32
+ const orderLabels = SORT_ORDER_LABELS[sortBy];
33
+ // The label picks are drafted while the Filter menu is open and applied in one go, so narrowing to
34
+ // three labels is one refetch rather than three.
35
+ const [filterMenuOpen, setFilterMenuOpen] = useState(false);
36
+ const [creatingLabel, setCreatingLabel] = useState(false);
37
+ const labelDraft = useLabelDraft(labelUids, [], filterMenuOpen);
38
+ const chosenLabelName = labels.find((l) => l.uid === labelUids[0])?.name;
39
+ // What the button says it is set to: the named filter, the labels, or both.
40
+ const activeParts = [
41
+ ...(activeFilter ? [activeFilter] : []),
42
+ ...(labelUids.length === 1 ? [chosenLabelName ?? "1 label"] : labelUids.length > 1 ? [`${labelUids.length} labels`] : []),
43
+ ];
44
+ const filterButtonLabel = activeParts.length > 0 ? `Filter: ${activeParts.join(", ")}` : "Filter";
45
+ const filterSections = [
46
+ {
47
+ key: "filter",
48
+ label: "Filter",
49
+ items: MAIL_LIST_FILTERS.map((entry) => ({
50
+ key: entry.value,
51
+ label: entry.label,
52
+ role: "menuitemradio",
53
+ checked: filter === entry.value,
54
+ onSelect: () => onFilterChange(entry.value),
55
+ })),
56
+ },
57
+ ];
58
+ if (offerClassificationFilters) {
59
+ filterSections.push({
60
+ key: "classification",
61
+ label: "Focused Inbox",
62
+ note: "One filter at a time: picking Focused or Other replaces the filter above.",
63
+ items: MAIL_LIST_CLASSIFICATION_FILTERS.map((entry) => ({
64
+ key: entry.value,
65
+ label: entry.label,
66
+ role: "menuitemradio",
67
+ checked: filter === entry.value,
68
+ onSelect: () => onFilterChange(entry.value),
69
+ })),
70
+ });
71
+ }
72
+ filterSections.push({
73
+ key: "labels",
74
+ items: [
75
+ {
76
+ key: "labels",
77
+ label: labelUids.length > 0 ? `Labels (${labelUids.length})` : "Labels",
78
+ description: "Show only messages with the labels you pick",
79
+ // A submenu rather than another group: a mailbox can have far more labels than the rest of
80
+ // this menu has rows, and they are picked several at a time rather than one instead of another.
81
+ submenu: labelSections({
82
+ labels,
83
+ state: labelDraft,
84
+ note: "Shows messages with any of the ticked labels.",
85
+ emptyNote: "This mailbox has no labels yet.",
86
+ commit: { label: "Apply labels", onSelect: () => onLabelUidsChange(labelDraft.draft), disabled: !labelDraft.dirty },
87
+ onCreate: () => setCreatingLabel(true),
88
+ // Clearing the label filter is worth doing in one step, so this applies straight away.
89
+ clear: { label: "Clear labels", keepOpen: false, disabled: labelUids.length === 0, onSelect: () => onLabelUidsChange([]) },
90
+ }),
91
+ },
92
+ ],
93
+ });
94
+ const sortSections = [
95
+ {
96
+ key: "sortBy",
97
+ label: "Sort by",
98
+ note: sortKeysNote,
99
+ items: MAIL_LIST_SORTS.map((entry) => ({
100
+ key: entry.value,
101
+ label: entry.label,
102
+ // The reason a key is unavailable is shown on the row itself rather than folded into the
103
+ // group's note, so it sits beside the one key it explains.
104
+ description: unavailableSortKeys?.[entry.value],
105
+ role: "menuitemradio",
106
+ checked: sortBy === entry.value,
107
+ disabled: sortKeysDisabled || entry.value in (unavailableSortKeys ?? {}),
108
+ // Switching field resets the direction to the one that reads naturally for it, rather than
109
+ // carrying Date's newest-first over to Subject as a surprising Z-A.
110
+ onSelect: () => onSortChange(entry.value, defaultSortOrder(entry.value)),
111
+ })),
112
+ },
113
+ {
114
+ key: "sortOrder",
115
+ label: "Order",
116
+ items: ["desc", "asc"].map((order) => ({
117
+ key: order,
118
+ label: orderLabels[order],
119
+ role: "menuitemradio",
120
+ checked: sortOrder === order,
121
+ disabled: sortKeysDisabled,
122
+ onSelect: () => onSortChange(sortBy, order),
123
+ })),
124
+ },
125
+ {
126
+ key: "arrange",
127
+ items: [
128
+ {
129
+ key: "conversations",
130
+ label: "Show as conversations",
131
+ description: "Group replies into one row",
132
+ role: "menuitemcheckbox",
133
+ checked: showAsConversations,
134
+ onSelect: () => onShowAsConversationsChange(!showAsConversations),
135
+ },
136
+ ],
137
+ },
138
+ ];
139
+ return (_jsxs("div", { className: "flex items-center gap-0.5 px-1.5 py-1 border-b border-border", children: [_jsx(NewLabelDialog, { open: creatingLabel, onClose: () => setCreatingLabel(false), mailboxUid: mailboxUid, onCreated: onLabelCreated }), _jsx(MenuButton, { "aria-label": filterButtonLabel, label: filterButtonLabel, icon: _jsx(HiOutlineAdjustmentsHorizontal, { size: 16, "aria-hidden": "true", className: "shrink-0 text-text-muted" }), onOpenChange: setFilterMenuOpen, sections: filterSections, disabled: filterDisabled, title: filterDisabled ? filterDisabledReason : undefined }), _jsx(MenuButton, { "aria-label": `Sort: ${sortLabel(sortBy)}`, label: `Sort: ${sortLabel(sortBy)}`, icon: _jsx(HiOutlineBarsArrowDown, { size: 16, "aria-hidden": "true", className: "shrink-0 text-text-muted" }), sections: sortSections }), _jsx("button", { type: "button", "aria-pressed": selectMode, disabled: selectDisabled,
140
+ // Icon-only, so the tooltip is the only thing naming it on screen - replaced by the reason
141
+ // while it is unavailable, which is the more useful thing to read at that moment.
142
+ title: selectDisabled ? selectDisabledReason : "Select", onClick: () => onSelectModeChange(!selectMode), "aria-label": "Select", className: [
143
+ // Icon-only, like Outlook's own "select items" command: the glyph carries it and the
144
+ // name lives in `aria-label`/`title`. The pressed state is a filled, outlined box
145
+ // rather than only a colour change, so it stays legible next to a disabled one.
146
+ "ml-auto shrink-0 inline-flex items-center justify-center p-1.5 rounded-md hover:bg-surface-alt disabled:opacity-50 disabled:hover:bg-transparent",
147
+ selectMode ? "bg-primary/10 text-primary-dark" : "text-text-muted",
148
+ ].join(" "), children: _jsx(HiOutlineStop, { size: 18, "aria-hidden": "true", className: "shrink-0" }) })] }));
149
+ }
@@ -0,0 +1,59 @@
1
+ import React from "react";
2
+ import { Folder, Message } from "@rapidmx/react-shared/mail/mailApi.js";
3
+ import { Label } from "@rapidmx/react-shared/mail/labelsApi.js";
4
+ export interface MailSelectionBarProps {
5
+ selected: Message[];
6
+ /** Every message currently listed - what "Select all" selects and what `allSelected` is measured against. */
7
+ listed: Message[];
8
+ /** What the bar *counts*, when the rows being ticked aren't messages themselves: the conversation list
9
+ * ticks conversations, each standing for several messages, so "3 conversations selected" is what the
10
+ * reader picked while `selected` stays the messages every action below acts on. Left out by the flat
11
+ * message list, which counts `selected`/`listed` directly. */
12
+ totals?: {
13
+ selected: number;
14
+ listed: number;
15
+ noun: string;
16
+ };
17
+ onSelectAll: () => void;
18
+ onClearSelection: () => void;
19
+ /** Leaves select mode entirely (and clears the selection). */
20
+ onCancel: () => void;
21
+ folders: Folder[];
22
+ currentFolderUid?: string;
23
+ /** Every label this mailbox has, for the Apply label action. */
24
+ labels: Label[];
25
+ /** The mailbox a label created from Apply label belongs to. */
26
+ mailboxUid: string;
27
+ onLabelCreated: (label: Label) => void;
28
+ /** A folder created from the Move to prompt, for the caller's own folder list and the sidebar. */
29
+ onFolderCreated: (folder: Folder) => void;
30
+ /** Sets the selection's labels: every message ends up with `labelUids`, plus whichever of
31
+ * `keepPartial` it already had (those rows were left partially applied, so each message keeps what
32
+ * it has). */
33
+ onApplyLabels: (labelUids: string[], keepPartial: string[]) => void;
34
+ onSetRead: (read: boolean) => void;
35
+ onSetFlagged: (flagged: boolean) => void;
36
+ onArchive: () => void;
37
+ /** Moves the selection into `folderUid`, resolving once the bulk update has settled. It resolves
38
+ * whether or not that update was rejected: a bulk update is applied element by element, so a failure
39
+ * is explained in this bar (along with the reload it triggers) rather than in the prompt, which would
40
+ * be claiming the move simply didn't happen. */
41
+ onMoveTo: (folderUid: string) => Promise<void>;
42
+ onReportJunk: () => void;
43
+ onDelete: () => void;
44
+ /** A bulk action is in flight - every action is held until it settles, since the next one would send
45
+ * `version`s the first has already superseded. */
46
+ busy: boolean;
47
+ error: string | null;
48
+ }
49
+ /**
50
+ * The header that replaces the list's own toolbar while select mode is on: how many rows are selected,
51
+ * select-all/clear, and the bulk actions Outlook offers for a multi-selection - mark read/unread, flag,
52
+ * archive, move, report junk and delete.
53
+ *
54
+ * "Delete" means *move to Deleted Items*, never a permanent erase: there is no bulk permanent-delete API,
55
+ * and the collection-level DELETE truncates the folder rather than removing a selection. An action whose
56
+ * target folder is the one already being viewed is disabled with a reason rather than hidden, so the set
57
+ * of actions doesn't shift around between folders.
58
+ */
59
+ export default function MailSelectionBar({ selected, listed, totals, onSelectAll, onClearSelection, onCancel, folders, currentFolderUid, labels, mailboxUid, onLabelCreated, onFolderCreated, onApplyLabels, onSetRead, onSetFlagged, onArchive, onMoveTo, onReportJunk, onDelete, busy, error, }: MailSelectionBarProps): React.JSX.Element;
@@ -0,0 +1,48 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ ///////////////////////////////////////////////////////////////////////////////
3
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
4
+ // SPDX-License-Identifier: MPL-2.0
5
+ ///////////////////////////////////////////////////////////////////////////////
6
+ import { useState } from "react";
7
+ import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
8
+ import LabelMenuButton from "./labelMenu.js";
9
+ import MoveToFolderDialog, { MOVE_TARGET_TYPES } from "./MoveToFolderDialog.js";
10
+ function actionClassName() {
11
+ return "px-2 py-1 rounded-md text-sm text-text hover:bg-surface-alt disabled:opacity-50 disabled:hover:bg-transparent";
12
+ }
13
+ /**
14
+ * The header that replaces the list's own toolbar while select mode is on: how many rows are selected,
15
+ * select-all/clear, and the bulk actions Outlook offers for a multi-selection - mark read/unread, flag,
16
+ * archive, move, report junk and delete.
17
+ *
18
+ * "Delete" means *move to Deleted Items*, never a permanent erase: there is no bulk permanent-delete API,
19
+ * and the collection-level DELETE truncates the folder rather than removing a selection. An action whose
20
+ * target folder is the one already being viewed is disabled with a reason rather than hidden, so the set
21
+ * of actions doesn't shift around between folders.
22
+ */
23
+ export default function MailSelectionBar({ selected, listed, totals, onSelectAll, onClearSelection, onCancel, folders, currentFolderUid, labels, mailboxUid, onLabelCreated, onFolderCreated, onApplyLabels, onSetRead, onSetFlagged, onArchive, onMoveTo, onReportJunk, onDelete, busy, error, }) {
24
+ const selectedCount = totals?.selected ?? selected.length;
25
+ const listedCount = totals?.listed ?? listed.length;
26
+ // Counted on the rows that were ticked, but *emptied* on the messages: a ticked conversation whose
27
+ // messages are still being fetched has nothing for an action to act on yet.
28
+ const none = selectedCount === 0 || selected.length === 0;
29
+ const allSelected = listedCount > 0 && selectedCount === listedCount;
30
+ const currentType = folders.find((folder) => folder.uid === currentFolderUid)?.type;
31
+ const moveTargets = folders.filter((folder) => MOVE_TARGET_TYPES.has(folder.type) && folder.uid !== currentFolderUid);
32
+ // The same prompt the reading pane's own Move to opens, so one list of destinations and one way to
33
+ // create a folder serve both.
34
+ const [movePrompt, setMovePrompt] = useState(false);
35
+ // None of the three needs its folder to exist first - the caller creates or lazily provisions it (see
36
+ // `resolveFolderOfType()` and `bulkArchive()` in `apps/www/index.tsx`) - so the only thing that disables
37
+ // them is already being in the folder they would move to.
38
+ const archiveReason = currentType === "archive" ? "These messages are already in Archive" : undefined;
39
+ const junkReason = currentType === "junk" ? "These messages are already in Junk" : undefined;
40
+ const deleteReason = currentType === "deleted_items" ? "These messages are already in Deleted Items" : undefined;
41
+ // A label every selected message already carries starts ticked; one only some of them carry starts
42
+ // partially applied, and is left exactly as it is unless the reader touches that row.
43
+ const appliedToAll = labels.filter((label) => !none && selected.every((m) => m.labelUids?.includes(label.uid)));
44
+ const appliedToSome = labels.filter((label) => selected.some((m) => m.labelUids?.includes(label.uid)) && !appliedToAll.includes(label));
45
+ return (_jsxs("div", { className: "border-b border-border", children: [_jsx(MoveToFolderDialog, { open: movePrompt, onClose: () => setMovePrompt(false), mailboxUid: mailboxUid, folders: folders, currentFolderUid: currentFolderUid, count: selected.length, onMove: onMoveTo, onFolderCreated: onFolderCreated }), _jsxs("div", { className: "flex items-center gap-2 px-3 py-1.5 bg-surface-alt", children: [_jsxs("span", { "aria-live": "polite", className: "text-sm font-semibold text-text", children: [selectedCount, totals ? ` ${totals.noun}${selectedCount === 1 ? "" : "s"}` : "", " selected"] }), _jsx("button", { type: "button", onClick: onSelectAll, disabled: allSelected || listedCount === 0, className: actionClassName(), children: "Select all" }), _jsx("button", { type: "button", onClick: onClearSelection, disabled: selectedCount === 0, className: actionClassName(), children: "Clear" }), _jsx("button", { type: "button", onClick: onCancel, className: `${actionClassName()} ml-auto`, children: "Cancel" })] }), _jsxs("div", { className: "flex flex-wrap items-center gap-0.5 px-1.5 py-1", children: [_jsx("button", { type: "button", onClick: () => onSetRead(true), disabled: none || busy, className: actionClassName(), children: "Mark read" }), _jsx("button", { type: "button", onClick: () => onSetRead(false), disabled: none || busy, className: actionClassName(), children: "Mark unread" }), _jsx("button", { type: "button", onClick: () => onSetFlagged(true), disabled: none || busy, className: actionClassName(), children: "Flag" }), _jsx("button", { type: "button", onClick: () => onSetFlagged(false), disabled: none || busy, className: actionClassName(), children: "Unflag" }), _jsx("button", { type: "button", onClick: onArchive, disabled: none || busy || !!archiveReason, title: archiveReason, className: actionClassName(), children: "Archive" }), _jsx(LabelMenuButton, { "aria-label": "Apply label", label: "Apply label", className: "text-sm", labels: labels, mailboxUid: mailboxUid, onLabelCreated: onLabelCreated, applied: appliedToAll.map((label) => label.uid), partial: appliedToSome.map((label) => label.uid), onCommit: onApplyLabels, busy: busy, disabled: none || busy, note: selected.length === 1
46
+ ? "Ticked labels are applied, unticked ones removed."
47
+ : "Ticked labels are applied to every selected message and unticked ones removed from all of them; a dash means only some have that label, and leaving it alone keeps it that way.", emptyNote: "This mailbox has no labels yet.", commit: { label: "Apply" }, clear: { label: "Remove all labels" } }), _jsx("button", { type: "button", onClick: () => setMovePrompt(true), disabled: none || busy, className: actionClassName(), children: "Move to" }), _jsx("button", { type: "button", onClick: onReportJunk, disabled: none || busy || !!junkReason, title: junkReason, className: actionClassName(), children: "Report junk" }), _jsx("button", { type: "button", onClick: onDelete, disabled: none || busy || !!deleteReason, title: deleteReason, className: actionClassName(), children: "Delete" })] }), error && (_jsx("div", { className: "px-3 pb-2", children: _jsx(Alert, { children: error }) }))] }));
48
+ }
@@ -0,0 +1,71 @@
1
+ import React, { ReactNode } from "react";
2
+ /** What every row has. `checked` turns it into a radio/checkbox item (the caller says which via `role`) and
3
+ * draws the checkmark column; without it the row is a plain command. */
4
+ interface MenuItemBase {
5
+ key: string;
6
+ label: string;
7
+ /** A one-line explanation under the label, for an item whose effect isn't obvious from its name. */
8
+ description?: string;
9
+ role?: "menuitem" | "menuitemradio" | "menuitemcheckbox";
10
+ /** `"mixed"` is ARIA's third checkbox state - some of the things this row acts on have it and some
11
+ * don't, which is what a label applied to only part of a multi-message selection looks like. */
12
+ checked?: boolean | "mixed";
13
+ /** A colour swatch before the label - a label's own colour. */
14
+ swatchColor?: string;
15
+ disabled?: boolean;
16
+ /** Leaves the menu open after choosing this row, for a multi-select list where several rows are ticked
17
+ * before one command commits them all. */
18
+ keepOpen?: boolean;
19
+ }
20
+ /** A row that does something when chosen. */
21
+ export interface MenuCommandSpec extends MenuItemBase {
22
+ onSelect: () => void;
23
+ submenu?: never;
24
+ }
25
+ /** A row that opens a submenu instead: choosing it replaces the menu's contents with these sections, under
26
+ * a Back row. One level deep, which is all any menu here needs - so it has nothing of its own to do. */
27
+ export interface MenuSubmenuSpec extends MenuItemBase {
28
+ submenu: MenuSectionSpec[];
29
+ onSelect?: never;
30
+ }
31
+ export type MenuItemSpec = MenuCommandSpec | MenuSubmenuSpec;
32
+ /** A labelled group of items. Groups after the first are drawn with a separator above them. */
33
+ export interface MenuSectionSpec {
34
+ key: string;
35
+ label?: string;
36
+ /** A short note under the group's items - e.g. why some of them are unavailable right now. */
37
+ note?: string;
38
+ items: MenuItemSpec[];
39
+ }
40
+ /** The height `PopoverPortal` is asked for - exact for a short menu, capped (the list scrolls) for a long one. */
41
+ export declare function menuHeight(sections: MenuSectionSpec[], width?: number): number;
42
+ export interface MenuButtonProps {
43
+ /** The trigger's visible label. */
44
+ label: ReactNode;
45
+ /** The trigger's accessible name, which also names the menu itself - includes the current selection
46
+ * where there is one ("Filter: Unread"), so the button says what it's set to, not just what it does. */
47
+ "aria-label": string;
48
+ icon?: ReactNode;
49
+ disabled?: boolean;
50
+ title?: string;
51
+ sections: MenuSectionSpec[];
52
+ width?: number;
53
+ /** Extra classes for the trigger, on top of the shared toolbar-button styling. */
54
+ className?: string;
55
+ /** Told whenever the menu opens or closes - how a caller resets a draft it keeps for the menu's own
56
+ * multi-select rows (see `useLabelDraft()`). */
57
+ onOpenChange?: (open: boolean) => void;
58
+ }
59
+ /**
60
+ * A toolbar button that opens an ARIA menu: `role="menu"` rows with checkmarks for the current choice,
61
+ * full keyboard support (Enter/Space or Arrow Down to open, arrows/Home/End to move with wrap, Enter/Space
62
+ * to choose, Escape or Tab to close with focus returning to the trigger) and a click outside to dismiss.
63
+ *
64
+ * Rendered through `PopoverPortal` rather than as an absolutely-positioned child: the mail list toolbar
65
+ * lives inside the list's own `overflow-y-auto` scroll container, which clips an absolute popup however
66
+ * high its `z-index` - the same reason Compose's own pickers portal out (see `PopoverPortal`'s own doc
67
+ * comment). The portal supplies the positioning, the outside-click dismissal and the box; the menu
68
+ * semantics, roving focus and keyboard handling are this component's own.
69
+ */
70
+ export default function MenuButton({ label, icon, disabled, title, sections, width, className, "aria-label": ariaLabel, onOpenChange, }: MenuButtonProps): React.JSX.Element;
71
+ export {};
@@ -0,0 +1,211 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ ///////////////////////////////////////////////////////////////////////////////
3
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
4
+ // SPDX-License-Identifier: MPL-2.0
5
+ ///////////////////////////////////////////////////////////////////////////////
6
+ import { useEffect, useRef, useState } from "react";
7
+ import { HiCheck, HiChevronDown, HiChevronLeft, HiChevronRight, HiMinus } from "react-icons/hi2";
8
+ import PopoverPortal from "@rapidmx/react-shared/components/overlays/PopoverPortal.js";
9
+ // `PopoverPortal` positions a fixed-size box (it has no auto-height mode), so the menu's height is
10
+ // computed from its own contents rather than measured - from whichever level is *shown*, so a submenu
11
+ // isn't left standing in the parent menu's taller box. These are the exact heights the classes below
12
+ // render at, so the box is never short enough to clip its last row: an item is `h-9`, a group label
13
+ // `h-6`, a note as many `leading-4` lines as it wraps to, a separator a 1px rule inside `my-1`, and the
14
+ // list itself `py-1`.
15
+ const ITEM_HEIGHT = 36;
16
+ const ITEM_DESCRIPTION_HEIGHT = 16;
17
+ const GROUP_LABEL_HEIGHT = 24;
18
+ const NOTE_LINE_HEIGHT = 16;
19
+ const NOTE_PADDING = 4;
20
+ /** A note wraps, so its height depends on how much of it fits a line: `text-xs` averages a little over 6px
21
+ * a character, and the note sits inside the list's `px-3`. Rounded so the estimate is never *under* the
22
+ * lines the browser actually draws - a box a few pixels too tall shows blank space, one too short clips. */
23
+ const NOTE_CHAR_WIDTH = 6.4;
24
+ const NOTE_PADDING_X = 24;
25
+ const SEPARATOR_HEIGHT = 9;
26
+ const LIST_PADDING = 8;
27
+ const MENU_MAX_HEIGHT = 460;
28
+ const DEFAULT_MENU_WIDTH = 248;
29
+ /** How tall `note` renders at `width`, wrapped. */
30
+ function noteHeight(note, width) {
31
+ const charsPerLine = Math.max(1, Math.floor((width - NOTE_PADDING_X) / NOTE_CHAR_WIDTH));
32
+ return Math.ceil(note.length / charsPerLine) * NOTE_LINE_HEIGHT + NOTE_PADDING;
33
+ }
34
+ /** The height `PopoverPortal` is asked for - exact for a short menu, capped (the list scrolls) for a long one. */
35
+ export function menuHeight(sections, width = DEFAULT_MENU_WIDTH) {
36
+ let height = LIST_PADDING;
37
+ sections.forEach((section, index) => {
38
+ if (index > 0) {
39
+ height += SEPARATOR_HEIGHT;
40
+ }
41
+ if (section.label) {
42
+ height += GROUP_LABEL_HEIGHT;
43
+ }
44
+ for (const item of section.items) {
45
+ height += ITEM_HEIGHT + (item.description ? ITEM_DESCRIPTION_HEIGHT : 0);
46
+ }
47
+ if (section.note) {
48
+ height += noteHeight(section.note, width);
49
+ }
50
+ });
51
+ return Math.min(height, MENU_MAX_HEIGHT);
52
+ }
53
+ /**
54
+ * A toolbar button that opens an ARIA menu: `role="menu"` rows with checkmarks for the current choice,
55
+ * full keyboard support (Enter/Space or Arrow Down to open, arrows/Home/End to move with wrap, Enter/Space
56
+ * to choose, Escape or Tab to close with focus returning to the trigger) and a click outside to dismiss.
57
+ *
58
+ * Rendered through `PopoverPortal` rather than as an absolutely-positioned child: the mail list toolbar
59
+ * lives inside the list's own `overflow-y-auto` scroll container, which clips an absolute popup however
60
+ * high its `z-index` - the same reason Compose's own pickers portal out (see `PopoverPortal`'s own doc
61
+ * comment). The portal supplies the positioning, the outside-click dismissal and the box; the menu
62
+ * semantics, roving focus and keyboard handling are this component's own.
63
+ */
64
+ export default function MenuButton({ label, icon, disabled, title, sections, width = DEFAULT_MENU_WIDTH, className = "", "aria-label": ariaLabel, onOpenChange, }) {
65
+ const [open, setOpen] = useState(false);
66
+ const [activeIndex, setActiveIndex] = useState(0);
67
+ // The key of the item whose submenu is showing, or `null` at the top level. Held as a key, not as the
68
+ // item itself, because `sections` is rebuilt on every render.
69
+ const [submenuKey, setSubmenuKey] = useState(null);
70
+ const triggerRef = useRef(null);
71
+ const itemRefs = useRef([]);
72
+ // State, not a ref: `PopoverPortal` renders nothing at all until its own positioning effect has run, so
73
+ // the rows don't exist yet when this component's effects first fire. Keying the focus effect on the
74
+ // menu node itself makes it run again once they do.
75
+ const [menuNode, setMenuNode] = useState(null);
76
+ const openSubmenu = sections
77
+ .flatMap((section) => section.items)
78
+ .find((item) => item.key === submenuKey && !!item.submenu);
79
+ /** The row that leaves a submenu again - synthesized rather than asked of the caller, so every submenu
80
+ * has the same way back however it was built. */
81
+ const backItem = {
82
+ key: "__back",
83
+ label: `Back to ${ariaLabel}`,
84
+ keepOpen: true,
85
+ onSelect: () => leaveSubmenu(),
86
+ };
87
+ const shownSections = openSubmenu
88
+ ? [{ key: "__back", items: [backItem] }, ...openSubmenu.submenu]
89
+ : sections;
90
+ const items = shownSections.flatMap((section) => section.items);
91
+ const enabledIndexes = items.map((item, index) => (item.disabled ? -1 : index)).filter((index) => index !== -1);
92
+ /** The row focus starts on: the current choice where there is one, else the first row that can take focus. */
93
+ function initialActiveIndex(within) {
94
+ const checked = within.findIndex((item) => item.checked === true && !item.disabled);
95
+ const enabled = within.map((item, index) => (item.disabled ? -1 : index)).filter((index) => index !== -1);
96
+ return checked === -1 ? (enabled[0] ?? 0) : checked;
97
+ }
98
+ function openMenu() {
99
+ setSubmenuKey(null);
100
+ setActiveIndex(initialActiveIndex(sections.flatMap((section) => section.items)));
101
+ setOpen(true);
102
+ onOpenChange?.(true);
103
+ }
104
+ function closeMenu(returnFocus) {
105
+ setOpen(false);
106
+ setSubmenuKey(null);
107
+ onOpenChange?.(false);
108
+ if (returnFocus) {
109
+ triggerRef.current?.focus();
110
+ }
111
+ }
112
+ function enterSubmenu(item) {
113
+ setSubmenuKey(item.key);
114
+ // Past the Back row, onto the first row of the submenu itself.
115
+ setActiveIndex(1 + initialActiveIndex(item.submenu.flatMap((section) => section.items)));
116
+ }
117
+ function leaveSubmenu() {
118
+ // Back on the row the submenu was opened from.
119
+ const parentIndex = sections.flatMap((section) => section.items).findIndex((item) => item.key === submenuKey);
120
+ setSubmenuKey(null);
121
+ setActiveIndex(parentIndex);
122
+ }
123
+ /** What a row does when it is chosen: open its submenu, or run it and close unless it asked to stay. */
124
+ function selectItem(item) {
125
+ if (item.submenu) {
126
+ enterSubmenu(item);
127
+ return;
128
+ }
129
+ item.onSelect();
130
+ if (!item.keepOpen) {
131
+ closeMenu(true);
132
+ }
133
+ }
134
+ // Roving focus: only the active row is tabbable, and it takes DOM focus whenever it changes, so the
135
+ // arrow keys move the screen reader's cursor too rather than only a visual highlight.
136
+ useEffect(() => {
137
+ if (open && menuNode) {
138
+ // A disabled row can't take focus, so a menu with nothing enabled focuses its own container -
139
+ // otherwise the keys it handles would go to whatever had focus before it opened.
140
+ if (enabledIndexes.length === 0) {
141
+ menuNode.focus();
142
+ }
143
+ else {
144
+ itemRefs.current[activeIndex]?.focus();
145
+ }
146
+ }
147
+ // `submenuKey` too: drilling in or out can land on the same index in the other level's list, and
148
+ // the row that index *means* is a different button, which the effect must move focus to.
149
+ }, [open, activeIndex, submenuKey, menuNode]);
150
+ function moveActive(delta) {
151
+ if (enabledIndexes.length === 0) {
152
+ return;
153
+ }
154
+ const position = enabledIndexes.indexOf(activeIndex);
155
+ setActiveIndex(enabledIndexes[(position + delta + enabledIndexes.length) % enabledIndexes.length]);
156
+ }
157
+ function handleMenuKeyDown(e) {
158
+ if (e.key === "ArrowDown") {
159
+ e.preventDefault();
160
+ moveActive(1);
161
+ }
162
+ else if (e.key === "ArrowUp") {
163
+ e.preventDefault();
164
+ moveActive(-1);
165
+ }
166
+ else if (e.key === "Home") {
167
+ e.preventDefault();
168
+ setActiveIndex(enabledIndexes[0] ?? 0);
169
+ }
170
+ else if (e.key === "End") {
171
+ e.preventDefault();
172
+ setActiveIndex(enabledIndexes[enabledIndexes.length - 1] ?? 0);
173
+ }
174
+ else if (e.key === "ArrowRight" && items[activeIndex]?.submenu) {
175
+ e.preventDefault();
176
+ enterSubmenu(items[activeIndex]);
177
+ }
178
+ else if ((e.key === "ArrowLeft" || e.key === "Escape") && openSubmenu) {
179
+ // ARIA's own submenu behaviour: Escape leaves the submenu for its parent menu rather than
180
+ // dismissing the whole thing.
181
+ e.preventDefault();
182
+ leaveSubmenu();
183
+ }
184
+ else if (e.key === "Escape" || e.key === "Tab") {
185
+ e.preventDefault();
186
+ closeMenu(true);
187
+ }
188
+ }
189
+ function handleTriggerKeyDown(e) {
190
+ if (!open && (e.key === "ArrowDown" || e.key === "ArrowUp")) {
191
+ e.preventDefault();
192
+ openMenu();
193
+ }
194
+ }
195
+ let itemIndex = -1;
196
+ return (_jsxs(_Fragment, { children: [_jsxs("button", { ref: triggerRef, type: "button", "aria-haspopup": "menu", "aria-expanded": open, "aria-label": ariaLabel, title: title, disabled: disabled, onClick: () => (open ? closeMenu(false) : openMenu()), onKeyDown: handleTriggerKeyDown, className: [
197
+ // `min-w-0` so a long label ("Filter: Has attachments") truncates instead of pushing
198
+ // whatever sits beside it in the toolbar off the edge of a 384px list column.
199
+ "inline-flex min-w-0 items-center gap-1 px-2 py-1 rounded-md text-sm text-text hover:bg-surface-alt disabled:opacity-50 disabled:hover:bg-transparent",
200
+ className,
201
+ ].join(" "), children: [icon, _jsx("span", { className: "truncate", children: label }), _jsx(HiChevronDown, { size: 14, "aria-hidden": "true", className: "shrink-0 text-text-muted" })] }), open && (_jsx(PopoverPortal, { anchorRef: triggerRef, onClose: () => closeMenu(false), width: width, height: menuHeight(shownSections, width), "aria-label": ariaLabel, children: _jsx("div", { ref: setMenuNode, role: "menu", tabIndex: -1, "aria-label": ariaLabel, onKeyDown: handleMenuKeyDown, className: "flex-1 overflow-y-auto py-1", children: shownSections.map((section, sectionIndex) => (_jsxs("div", { role: "group", "aria-label": section.label, className: sectionIndex > 0 ? "border-t border-border mt-1 pt-1" : "", children: [section.label && (_jsx("div", { "aria-hidden": "true", className: "h-6 flex items-center px-3 text-xs font-semibold uppercase tracking-wide text-text-muted", children: section.label })), section.items.map((item) => {
202
+ itemIndex += 1;
203
+ const index = itemIndex;
204
+ const role = item.role ?? "menuitem";
205
+ return (_jsxs("button", { ref: (node) => {
206
+ itemRefs.current[index] = node;
207
+ }, type: "button", role: role, disabled: item.disabled, tabIndex: index === activeIndex ? 0 : -1, ...(role === "menuitem"
208
+ ? {}
209
+ : { "aria-checked": item.checked === "mixed" ? "mixed" : !!item.checked }), ...(item.submenu ? { "aria-haspopup": "menu", "aria-expanded": false } : {}), onClick: () => selectItem(item), className: "w-full flex items-start gap-2 px-3 py-2 text-left text-sm text-text hover:bg-surface-alt disabled:opacity-50 disabled:hover:bg-transparent", children: [_jsxs("span", { className: "w-4 shrink-0 flex justify-center pt-0.5", children: [item.key === "__back" && (_jsx(HiChevronLeft, { size: 14, "aria-hidden": "true", className: "text-text-muted" })), item.checked === "mixed" ? (_jsx(HiMinus, { size: 14, "aria-hidden": "true", className: "text-primary-dark" })) : (item.checked && _jsx(HiCheck, { size: 14, "aria-hidden": "true", className: "text-primary-dark" }))] }), _jsxs("span", { className: "min-w-0 flex-1", children: [_jsxs("span", { className: "flex h-5 items-center gap-1.5", children: [item.swatchColor && (_jsx("span", { "aria-hidden": "true", className: "w-2.5 h-2.5 rounded-full shrink-0", style: { backgroundColor: item.swatchColor } })), _jsx("span", { className: "truncate", children: item.label })] }), item.description && (_jsx("span", { className: "block h-4 text-xs leading-4 text-text-muted truncate font-normal", children: item.description }))] }), item.submenu && (_jsx(HiChevronRight, { size: 14, "aria-hidden": "true", className: "shrink-0 mt-0.5 text-text-muted" }))] }, item.key));
210
+ }), section.note && _jsx("p", { className: "px-3 py-1 text-xs leading-4 text-text-muted", children: section.note })] }, section.key))) }) }))] }));
211
+ }