@remit/ui 0.0.138 → 0.0.139

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/ui",
3
- "version": "0.0.138",
3
+ "version": "0.0.139",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "src"
@@ -55,7 +55,7 @@ export const FolderManager = ({
55
55
  delimiter={delimiter}
56
56
  labels={{
57
57
  treeAriaLabel: "Your folders",
58
- optionLabel: (label) => label,
58
+ optionLabel: (folder) => folder.label,
59
59
  ...labels,
60
60
  }}
61
61
  rowActions={(folder) => {
@@ -81,6 +81,20 @@ describe("FolderRow", () => {
81
81
  assert.ok(html.indexOf(">Delete<") > html.indexOf("</button>"));
82
82
  });
83
83
 
84
+ it("shows how much mail the folder holds, and leaves zero visible", () => {
85
+ assert.match(render({ messageCount: 512 }), />512</);
86
+ assert.match(render({ messageCount: 512 }), />msgs</);
87
+ assert.match(render({ messageCount: 1 }), />msg</);
88
+ assert.match(render({ messageCount: 0 }), />0</);
89
+ assert.doesNotMatch(render({}), /msgs/);
90
+ });
91
+
92
+ it("keeps the count out of the accessible name it was not folded into", () => {
93
+ const html = render({ messageCount: 512 });
94
+ assert.match(html, /aria-label="Move to Travel"/);
95
+ assert.match(html, /aria-hidden="true"[^>]*class="[^"]*text-2xs/);
96
+ });
97
+
84
98
  it("takes its place in a roving tab order", () => {
85
99
  assert.match(render({ tabIndex: 0 }), /tabindex="0"/);
86
100
  assert.match(render({ tabIndex: -1 }), /tabindex="-1"/);
@@ -50,6 +50,12 @@ export interface FolderRowProps {
50
50
  current?: boolean;
51
51
  /** Inline tag shown on the current row, e.g. `current`. */
52
52
  currentTag?: string;
53
+ /**
54
+ * How much mail the folder holds, right-aligned. The accessible name is the
55
+ * caller's `ariaLabel`, so a surface that needs the count announced folds it
56
+ * in there rather than relying on this.
57
+ */
58
+ messageCount?: number;
53
59
  /** A hairline under the label, drawn for every row but the last. */
54
60
  separated?: boolean;
55
61
  /**
@@ -77,6 +83,7 @@ export const FolderRow = ({
77
83
  context = false,
78
84
  current = false,
79
85
  currentTag,
86
+ messageCount,
80
87
  separated = false,
81
88
  actions,
82
89
  tabIndex,
@@ -96,6 +103,12 @@ export const FolderRow = ({
96
103
  const icon = (
97
104
  <Folder className="size-4 shrink-0 text-fg-subtle" aria-hidden="true" />
98
105
  );
106
+ const count =
107
+ messageCount === undefined ? null : (
108
+ <span aria-hidden="true" className="shrink-0 text-2xs text-fg-subtle">
109
+ {messageCount} {messageCount === 1 ? "msg" : "msgs"}
110
+ </span>
111
+ );
99
112
  if (context) {
100
113
  return (
101
114
  <div className="relative flex items-center">
@@ -112,6 +125,7 @@ export const FolderRow = ({
112
125
  {chevron}
113
126
  {icon}
114
127
  <span className="min-w-0 flex-1 truncate">{label}</span>
128
+ {count}
115
129
  </div>
116
130
  {actions}
117
131
  {separated && <FolderRowSeparator depth={depth} />}
@@ -142,6 +156,7 @@ export const FolderRow = ({
142
156
  {chevron}
143
157
  {icon}
144
158
  <span className="min-w-0 flex-1 truncate">{label}</span>
159
+ {count}
145
160
  {current && currentTag && (
146
161
  <span className="shrink-0 text-xs text-fg-muted">{currentTag}</span>
147
162
  )}
@@ -101,8 +101,20 @@ describe("FolderTreePicker render", () => {
101
101
 
102
102
  it("applies caller-supplied labels", () => {
103
103
  const html = render({
104
- labels: { optionLabel: (label) => `Verplaats naar ${label}` },
104
+ labels: { optionLabel: (folder) => `Verplaats naar ${folder.label}` },
105
105
  });
106
106
  assert.match(html, /aria-label="Verplaats naar Archive"/);
107
107
  });
108
+
109
+ it("announces the message count as part of the row's own name", () => {
110
+ const html = render({
111
+ folders: [{ id: "t", label: "Trash", path: "Trash", messageCount: 512 }],
112
+ labels: {
113
+ optionLabel: (folder) =>
114
+ `Set ${folder.label}, ${folder.messageCount} messages, as Trash`,
115
+ },
116
+ });
117
+ assert.match(html, /aria-label="Set Trash, 512 messages, as Trash"/);
118
+ assert.match(html, />512</);
119
+ });
108
120
  });
@@ -55,8 +55,12 @@ export interface FolderTreePickerLabels {
55
55
  emptyMessage?: (query: string) => string;
56
56
  /** Shown when there is no folder to list at all, filter or no filter. */
57
57
  noFolders?: string;
58
- /** Accessible label for a selectable row, e.g. `Move to X`. */
59
- optionLabel?: (label: string) => string;
58
+ /**
59
+ * Accessible label for a selectable row. Takes the folder rather than its
60
+ * label, so a surface can announce the message count as part of the name
61
+ * instead of leaving it as decoration beside it.
62
+ */
63
+ optionLabel?: (folder: FolderTreeNode) => string;
60
64
  newFolder?: string;
61
65
  newSubfolder?: (label: string) => string;
62
66
  nameLabel?: string;
@@ -113,7 +117,7 @@ const defaultLabels: Required<FolderTreePickerLabels> = {
113
117
  contextSuffix: "(containing folder)",
114
118
  emptyMessage: (query) => `No folders match "${query}"`,
115
119
  noFolders: "No folders to show",
116
- optionLabel: (label) => `Move to ${label}`,
120
+ optionLabel: (folder) => `Move to ${folder.label}`,
117
121
  newFolder: "New folder",
118
122
  newSubfolder: (label) => `New folder inside ${label}`,
119
123
  topLevel: "Top level",
@@ -376,7 +380,7 @@ export const FolderTreePicker = ({
376
380
 
377
381
  const rowAriaLabel = (row: FolderTreeRow): string => {
378
382
  if (row.context) return `${row.folder.label} ${text.contextSuffix}`;
379
- if (isSelectable(row)) return text.optionLabel(row.folder.label);
383
+ if (isSelectable(row)) return text.optionLabel(row.folder);
380
384
  return `${row.folder.label} ${text.currentSuffix}`;
381
385
  };
382
386
 
@@ -394,6 +398,7 @@ export const FolderTreePicker = ({
394
398
  context={row.context}
395
399
  current={folder.isCurrent}
396
400
  currentTag={text.currentTag}
401
+ messageCount={folder.messageCount}
397
402
  selected={selectable && folder.id === selectedId}
398
403
  separated={separated}
399
404
  actions={rowActions?.(folder)}
@@ -4,6 +4,7 @@ import { createElement } from "react";
4
4
  import { renderToString } from "react-dom/server";
5
5
  import {
6
6
  type CandidateFolder,
7
+ type RoleAppointment,
7
8
  RoleAppointmentList,
8
9
  } from "./role-appointment-list.js";
9
10
 
@@ -24,8 +25,13 @@ const folders: CandidateFolder[] = [
24
25
  },
25
26
  ];
26
27
 
28
+ const appointed = (mailboxId: string): RoleAppointment => ({
29
+ mailboxId,
30
+ source: "Appointed",
31
+ });
32
+
27
33
  function render(
28
- appointments: Record<string, string | null>,
34
+ appointments: Record<string, RoleAppointment>,
29
35
  displayNames: Record<string, string> = {},
30
36
  ): string {
31
37
  return renderToString(
@@ -42,7 +48,7 @@ function render(
42
48
 
43
49
  describe("RoleAppointmentList", () => {
44
50
  it("titles the section with the account email", () => {
45
- const html = render({ inbox: "mb-inbox" });
51
+ const html = render({ inbox: appointed("mb-inbox") });
46
52
  assert.match(html, /Folder roles —/);
47
53
  assert.match(html, /you@example.com/);
48
54
  });
@@ -69,29 +75,143 @@ describe("RoleAppointmentList", () => {
69
75
  });
70
76
 
71
77
  it("shows the appointed folder's path and count under the role", () => {
72
- const html = render({ drafts: "mb-concepten" });
78
+ const html = render({ drafts: appointed("mb-concepten") });
73
79
  assert.match(html, /title="INBOX\/Concepten"/);
74
80
  assert.match(html, /340/);
75
81
  assert.match(html, /messages/);
76
82
  });
77
83
 
78
84
  it("renders a rename field only for an appointed role", () => {
79
- const html = render({ drafts: "mb-concepten" });
85
+ const html = render({ drafts: appointed("mb-concepten") });
80
86
  assert.match(html, /Display name for Drafts/);
81
87
  assert.doesNotMatch(html, /Display name for Sent/);
82
88
  });
83
89
 
84
90
  it("lists unappointed folders under Other folders", () => {
85
- const html = render({ drafts: "mb-concepten", inbox: "mb-inbox" });
91
+ const html = render({
92
+ drafts: appointed("mb-concepten"),
93
+ inbox: appointed("mb-inbox"),
94
+ });
86
95
  assert.match(html, /Other folders/);
87
96
  assert.match(html, /Nieuwsbrieven/);
88
97
  assert.match(html, /Drafts/);
89
98
  });
90
99
 
91
100
  it("keeps an appointed folder out of the Other folders list", () => {
92
- const html = render({ drafts: "mb-concepten" });
101
+ const html = render({ drafts: appointed("mb-concepten") });
93
102
  const otherIdx = html.indexOf("Other folders");
94
103
  assert.ok(otherIdx >= 0);
95
104
  assert.doesNotMatch(html.slice(otherIdx), /Concepten/);
96
105
  });
106
+
107
+ it("says where each answer came from, in front of the path", () => {
108
+ assert.match(
109
+ render({ drafts: appointed("mb-concepten") }),
110
+ /Chosen by you · INBOX\/Concepten/,
111
+ );
112
+ assert.match(
113
+ render({ drafts: { mailboxId: "mb-concepten", source: "Flagged" } }),
114
+ /The mail server marks it as Drafts · INBOX\/Concepten/,
115
+ );
116
+ assert.match(
117
+ render({ inbox: { mailboxId: "mb-inbox", source: "Reserved" } }),
118
+ /The account&#x27;s own INBOX · INBOX/,
119
+ );
120
+ assert.match(
121
+ render({ drafts: { mailboxId: "mb-concepten", source: "Proposed" } }),
122
+ /Matched by name, not confirmed · INBOX\/Concepten/,
123
+ );
124
+ });
125
+
126
+ it("announces the provenance with the control that changes it", () => {
127
+ const html = render({ drafts: appointed("mb-concepten") });
128
+ const described = /aria-describedby="([^"]+)"/.exec(html);
129
+ assert.ok(described);
130
+ assert.match(html, new RegExp(`id="${described[1]}"`));
131
+ });
132
+
133
+ it("reads an unfilled role as a decision waiting, not an error", () => {
134
+ const html = render({ archive: { mailboxId: null, source: "None" } });
135
+ assert.match(
136
+ html,
137
+ /Not set — pick the folder this account uses for Archive\.</,
138
+ );
139
+ });
140
+
141
+ it("says only Trash gates a verb when it is unfilled", () => {
142
+ const html = render({ trash: { mailboxId: null, source: "None" } });
143
+ assert.match(html, /Deleting mail needs one\./);
144
+ });
145
+
146
+ it("calls out a stale Trash with the folder that vanished and its repair", () => {
147
+ const html = render({
148
+ trash: {
149
+ mailboxId: null,
150
+ source: "Stale",
151
+ staleFolderPath: "INBOX/Prullenbak",
152
+ },
153
+ });
154
+ assert.match(
155
+ html,
156
+ /The folder you chose for Trash — INBOX\/Prullenbak — is gone from the mail server\./,
157
+ );
158
+ assert.match(html, /Deleting mail is stopped until you pick another one\./);
159
+ assert.match(html, /Pick a folder/);
160
+ assert.match(html, /role="alert"/);
161
+ });
162
+
163
+ it("names what reader fell back to for a stale non-Trash role", () => {
164
+ const html = render({
165
+ drafts: {
166
+ mailboxId: "mb-drafts",
167
+ source: "Stale",
168
+ staleFolderPath: "INBOX/Concepten",
169
+ },
170
+ });
171
+ assert.match(
172
+ html,
173
+ /The folder you chose for Drafts — INBOX\/Concepten — is gone from the mail server\./,
174
+ );
175
+ assert.match(html, /reader is using Drafts instead\./);
176
+ assert.doesNotMatch(html, /Deleting mail is stopped/);
177
+ });
178
+
179
+ it("drops the name clause when no path was ever recorded", () => {
180
+ const html = render({ trash: { mailboxId: null, source: "Stale" } });
181
+ assert.match(
182
+ html,
183
+ /The folder you chose for Trash is gone from the mail server\./,
184
+ );
185
+ });
186
+
187
+ it("says nothing about a fallback it cannot name", () => {
188
+ const html = render({
189
+ drafts: {
190
+ mailboxId: null,
191
+ source: "Stale",
192
+ staleFolderPath: "INBOX/Concepten",
193
+ },
194
+ });
195
+ assert.match(html, /is gone from the mail server\.</);
196
+ assert.doesNotMatch(html, /reader is using/);
197
+ });
198
+
199
+ it("announces the stale callout with the control that repairs it", () => {
200
+ const html = render({
201
+ trash: {
202
+ mailboxId: null,
203
+ source: "Stale",
204
+ staleFolderPath: "INBOX/Prullenbak",
205
+ },
206
+ });
207
+ const notice = /id="([^"]+)"[^>]*>The folder you chose for Trash/.exec(
208
+ html,
209
+ );
210
+ assert.ok(notice, "the callout carries an id");
211
+ assert.match(
212
+ html,
213
+ new RegExp(`aria-describedby="${notice[1]}"`),
214
+ "the Select that repairs the role points at it",
215
+ );
216
+ });
97
217
  });
@@ -3,6 +3,7 @@ import { useState } from "react";
3
3
  import type { FolderRole } from "./folder-role.js";
4
4
  import {
5
5
  type CandidateFolder,
6
+ type RoleAppointment,
6
7
  RoleAppointmentList,
7
8
  } from "./role-appointment-list.js";
8
9
 
@@ -48,26 +49,45 @@ const HOSTNET_FOLDERS: readonly CandidateFolder[] = [
48
49
  { mailboxId: "mb-spam", providerPath: "INBOX/Spam", messageCount: 88 },
49
50
  ];
50
51
 
52
+ const appointed = (mailboxId: string): RoleAppointment => ({
53
+ mailboxId,
54
+ source: "Appointed",
55
+ });
56
+
57
+ /** Everything except the role each story is about, so one row carries the news. */
58
+ const SETTLED: Record<string, RoleAppointment> = {
59
+ inbox: { mailboxId: "mb-inbox", source: "Reserved" },
60
+ drafts: appointed("mb-concepten"),
61
+ sent: appointed("mb-sent-messages"),
62
+ archive: appointed("mb-archive"),
63
+ junk: appointed("mb-spam"),
64
+ trash: appointed("mb-deleted"),
65
+ };
66
+
51
67
  function Harness({
52
68
  folders,
53
69
  initial,
54
70
  }: {
55
71
  folders: readonly CandidateFolder[];
56
- initial: Record<string, string | null>;
72
+ initial: Record<string, RoleAppointment>;
57
73
  }) {
58
74
  const [appointments, setAppointments] = useState(initial);
59
75
  const [displayNames, setDisplayNames] = useState<Record<string, string>>({});
60
76
 
61
77
  const handleAppoint = (role: FolderRole, mailboxId: string | null) => {
62
78
  setAppointments((prev) => {
63
- const next: Record<string, string | null> = {
79
+ const next: Record<string, RoleAppointment> = {
64
80
  ...prev,
65
- [role]: mailboxId,
81
+ [role]: mailboxId
82
+ ? { mailboxId, source: "Appointed" }
83
+ : { mailboxId: null, source: "None" },
66
84
  };
67
85
  // Exclusivity: appointing a folder to one role clears it from any other.
68
86
  if (mailboxId) {
69
- for (const r of Object.keys(next)) {
70
- if (r !== role && next[r] === mailboxId) next[r] = null;
87
+ for (const other of Object.keys(next)) {
88
+ if (other === role) continue;
89
+ if (next[other]?.mailboxId !== mailboxId) continue;
90
+ next[other] = { mailboxId: null, source: "None" };
71
91
  }
72
92
  }
73
93
  return next;
@@ -105,34 +125,96 @@ type Story = StoryObj<typeof Harness>;
105
125
  * The empty look-alikes drop to "Other folders".
106
126
  */
107
127
  export const Hostnet: Story = {
128
+ args: { folders: HOSTNET_FOLDERS, initial: SETTLED },
129
+ };
130
+
131
+ /**
132
+ * Flag-first proposal before the user corrects it: detection appointed the
133
+ * `\Drafts`-flagged but empty `INBOX/Drafts` (0) and the empty `INBOX/Sent`.
134
+ * The picker counts reveal the real folders so the user can re-appoint.
135
+ */
136
+ export const ProposedDefaults: Story = {
108
137
  args: {
109
138
  folders: HOSTNET_FOLDERS,
110
139
  initial: {
111
- inbox: "mb-inbox",
112
- drafts: "mb-concepten",
113
- sent: "mb-sent-messages",
114
- archive: "mb-archive",
115
- junk: "mb-spam",
116
- trash: "mb-deleted",
140
+ ...SETTLED,
141
+ drafts: { mailboxId: "mb-drafts", source: "Flagged" },
142
+ sent: { mailboxId: "mb-sent", source: "Flagged" },
143
+ },
144
+ },
145
+ };
146
+
147
+ /** `Appointed` — a person decided, and the row says so. */
148
+ export const AppointedSource: Story = {
149
+ name: "appointed",
150
+ args: { folders: HOSTNET_FOLDERS, initial: SETTLED },
151
+ };
152
+
153
+ /** `Flagged` — the mail server's own SPECIAL-USE flag, not a guess. */
154
+ export const FlaggedSource: Story = {
155
+ name: "flagged",
156
+ args: {
157
+ folders: HOSTNET_FOLDERS,
158
+ initial: {
159
+ ...SETTLED,
160
+ trash: { mailboxId: "mb-deleted", source: "Flagged" },
161
+ },
162
+ },
163
+ };
164
+
165
+ /** `Reserved` — INBOX is the one role the protocol names for us. */
166
+ export const ReservedSource: Story = {
167
+ name: "reserved",
168
+ args: {
169
+ folders: HOSTNET_FOLDERS,
170
+ initial: {
171
+ ...SETTLED,
172
+ inbox: { mailboxId: "mb-inbox", source: "Reserved" },
173
+ },
174
+ },
175
+ };
176
+
177
+ /** `Proposed` — a name matched. Nobody confirmed it, and the row does not pretend otherwise. */
178
+ export const ProposedSource: Story = {
179
+ name: "proposed",
180
+ args: {
181
+ folders: HOSTNET_FOLDERS,
182
+ initial: {
183
+ ...SETTLED,
184
+ trash: { mailboxId: "mb-deleted", source: "Proposed" },
117
185
  },
118
186
  },
119
187
  };
120
188
 
121
189
  /**
122
- * Flag-first proposal before the user corrects it: detection appointed the
123
- * `\Drafts`-flagged but empty `INBOX/Drafts` (0) and the empty `INBOX/Sent`.
124
- * The picker counts reveal the real folders so the user can re-appoint.
190
+ * `Stale` the folder the user chose is gone from the mail server. The one row
191
+ * representing a broken decision, so it is a callout with its repair rather
192
+ * than a subtitle. Deleting mail is stopped until Trash is repaired.
125
193
  */
126
- export const ProposedDefaults: Story = {
194
+ export const StaleSource: Story = {
195
+ name: "stale",
196
+ args: {
197
+ folders: HOSTNET_FOLDERS,
198
+ initial: {
199
+ ...SETTLED,
200
+ trash: {
201
+ mailboxId: null,
202
+ source: "Stale",
203
+ staleFolderPath: "INBOX/Prullenbak",
204
+ },
205
+ },
206
+ },
207
+ };
208
+
209
+ /** `None` — a decision waiting to be made. No icon, no danger colour. */
210
+ export const NoneSource: Story = {
211
+ name: "none",
127
212
  args: {
128
213
  folders: HOSTNET_FOLDERS,
129
214
  initial: {
130
- inbox: "mb-inbox",
131
- drafts: "mb-drafts",
132
- sent: "mb-sent",
133
- archive: "mb-archive",
134
- junk: "mb-spam",
135
- trash: "mb-deleted",
215
+ ...SETTLED,
216
+ trash: { mailboxId: null, source: "None" },
217
+ archive: { mailboxId: null, source: "None" },
136
218
  },
137
219
  },
138
220
  };