@remit/ui 0.0.50 → 0.0.52

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.
@@ -1,4 +1,5 @@
1
- import { AlertCircle } from "lucide-react";
1
+ import { AlertCircle, Loader2 } from "lucide-react";
2
+ import type { ReactNode } from "react";
2
3
  import { Button } from "./button.js";
3
4
 
4
5
  /** Non-ready states the flat mailbox list can be in. "ready" renders rows. */
@@ -34,25 +35,148 @@ export function MessageListLoading() {
34
35
  }
35
36
 
36
37
  /**
37
- * Empty mailbox / empty-search state. Copy mirrors the live MessageList
38
- * EmptyState: a plain "No messages…" line, switching to the search variant
39
- * when a query is active.
38
+ * How much of the collection the filter was applied to.
39
+ *
40
+ * `whole-folder` is a SQL `where` over the mailbox — an empty result means the
41
+ * folder holds no matching mail. `loaded-pages` is a criterion evaluated over
42
+ * the rows fetched so far, which D19-S3 keeps alive for the off-row criteria
43
+ * (`senderTrust`, `dkimMismatch`) that page with a continuation token: an empty
44
+ * result there means nothing matched yet, not that nothing matches.
45
+ *
46
+ * A value rather than a `complete` flag, so the day a third reach exists the
47
+ * copy is a case to add and not a boolean to reinterpret.
40
48
  */
41
- export function MessageListEmpty({ searchQuery }: { searchQuery?: string }) {
42
- const isSearching = Boolean(searchQuery?.trim());
49
+ export type FilterReach = "whole-folder" | "loaded-pages";
50
+
51
+ /**
52
+ * The active category filter, the way out of it, and how far it reached. All
53
+ * three together: the label cannot arrive without its escape, and the reach
54
+ * cannot be left to a default, because it decides which completeness sentence
55
+ * is true.
56
+ */
57
+ export interface MessageListFilter {
58
+ /** Display label of the active category, e.g. "Personal". */
59
+ label: string;
60
+ reach: FilterReach;
61
+ onClear: () => void;
62
+ }
63
+
64
+ export interface MessageListEmptyProps {
65
+ /** Absent when no category filter is active. */
66
+ filter?: MessageListFilter;
67
+ /**
68
+ * Name of the collection being listed, e.g. "Inbox". Absent for a plain
69
+ * mailbox, which keeps the generic copy. Cross-account collections that are
70
+ * not mailboxes (Flagged) name themselves here rather than being called one.
71
+ */
72
+ scopeLabel?: string;
73
+ searchQuery?: string;
74
+ }
75
+
76
+ /**
77
+ * Empty list state. Unfiltered copy mirrors the live MessageList EmptyState: a
78
+ * plain "No messages…" line, switching to the search variant when a query is
79
+ * active.
80
+ *
81
+ * Under a filter it says how much was read (design D19). An empty list looks
82
+ * identical whether it is correct or broken, which is how #315's bug survived
83
+ * a mailbox holding thousands of matching messages. The sentence comes from the
84
+ * filter's own reach, so a filtered empty state always carries one and never
85
+ * claims more than the query did.
86
+ */
87
+ export function MessageListEmpty({
88
+ filter,
89
+ scopeLabel,
90
+ searchQuery,
91
+ }: MessageListEmptyProps) {
92
+ const query = searchQuery?.trim();
93
+
94
+ if (!filter) {
95
+ return (
96
+ <EmptyFrame>
97
+ <p className="text-fg-muted">{unfilteredCopy(query, scopeLabel)}</p>
98
+ </EmptyFrame>
99
+ );
100
+ }
101
+
102
+ return (
103
+ <EmptyFrame>
104
+ <p className="font-medium text-fg">
105
+ {filteredHeadline(filter.label, scopeLabel, query)}
106
+ </p>
107
+ <p className="mt-1 text-sm text-fg-muted">
108
+ {COMPLETENESS_COPY[filter.reach]}
109
+ </p>
110
+ <Button
111
+ variant="secondary"
112
+ size="sm"
113
+ className="mt-4"
114
+ onClick={filter.onClear}
115
+ >
116
+ Clear filter
117
+ </Button>
118
+ </EmptyFrame>
119
+ );
120
+ }
121
+
122
+ /**
123
+ * The one sentence a filtered empty list always carries. `loaded-pages` states
124
+ * what is true of a bounded read and nothing more; the wording it should settle
125
+ * on belongs with whoever moves the off-row criteria on-row (D7).
126
+ */
127
+ const COMPLETENESS_COPY: Record<FilterReach, string> = {
128
+ "whole-folder": "Every message in this folder was checked.",
129
+ "loaded-pages": "Only the messages loaded so far were checked.",
130
+ };
131
+
132
+ function unfilteredCopy(
133
+ query: string | undefined,
134
+ scopeLabel: string | undefined,
135
+ ): string {
136
+ if (query) return "No messages match your search";
137
+ if (scopeLabel) return `No messages in ${scopeLabel}`;
138
+ return "No messages in this mailbox";
139
+ }
140
+
141
+ /** One string, not JSX, so the copy stays a single text node and reads whole. */
142
+ function filteredHeadline(
143
+ filterLabel: string,
144
+ scopeLabel: string | undefined,
145
+ query: string | undefined,
146
+ ): string {
147
+ if (query) return `No results for “${query}” in ${filterLabel}`;
148
+ if (scopeLabel) return `No ${filterLabel} mail in ${scopeLabel}`;
149
+ return `No ${filterLabel} mail`;
150
+ }
151
+
152
+ function EmptyFrame({ children }: { children: ReactNode }) {
43
153
  return (
44
154
  <div className="flex h-full flex-1 items-center justify-center">
45
- <div className="flex flex-col items-center justify-center p-8 text-center">
46
- <p className="text-fg-muted">
47
- {isSearching
48
- ? "No messages match your search"
49
- : "No messages in this mailbox"}
50
- </p>
155
+ <div className="flex max-w-sm flex-col items-center justify-center p-8 text-center">
156
+ {children}
51
157
  </div>
52
158
  </div>
53
159
  );
54
160
  }
55
161
 
162
+ /**
163
+ * Another page in flight below rows already rendered. Says so in words: a bare
164
+ * spinner (`MessageList.tsx:1429-1433`) is one of the ways "not fetched yet"
165
+ * reads as "nothing there".
166
+ */
167
+ export function MessageListLoadingMore() {
168
+ return (
169
+ <div
170
+ className="flex items-center justify-center gap-2 py-4 text-sm text-fg-muted"
171
+ role="status"
172
+ aria-live="polite"
173
+ >
174
+ <Loader2 className="size-4 animate-spin" aria-hidden="true" />
175
+ <span>Loading more&hellip;</span>
176
+ </div>
177
+ );
178
+ }
179
+
56
180
  /**
57
181
  * Fail-hard list error (ux.md): a centered, blocking message that stops the
58
182
  * list — never a toast, never a control left looking healthy. States plainly
@@ -2,7 +2,7 @@ import assert from "node:assert/strict";
2
2
  import { describe, it } from "node:test";
3
3
  import { createElement } from "react";
4
4
  import { renderToString } from "react-dom/server";
5
- import type { ThreadRowData } from "./app-shell-types.js";
5
+ import { categoryTone, type ThreadRowData } from "./app-shell-types.js";
6
6
  import { ComfortableRow, CompactRow } from "./message-row.js";
7
7
 
8
8
  const base: ThreadRowData = {
@@ -35,6 +35,72 @@ describe("ComfortableRow", () => {
35
35
  assert.notEqual(unread, read);
36
36
  assert.match(unread, /font-semibold/);
37
37
  });
38
+
39
+ it("renders a chip for every label applied to the message (issue #26)", () => {
40
+ const html = renderToString(
41
+ createElement(ComfortableRow, {
42
+ thread: {
43
+ ...base,
44
+ isRead: true,
45
+ labels: [
46
+ { labelId: "l1", name: "Receipts", color: "Blue" },
47
+ { labelId: "l2", name: "Travel", color: "Green" },
48
+ ],
49
+ },
50
+ }),
51
+ );
52
+ assert.match(html, /Receipts/);
53
+ assert.match(html, /Travel/);
54
+ });
55
+
56
+ it("renders no label chip when the message carries none", () => {
57
+ const html = renderToString(
58
+ createElement(ComfortableRow, { thread: { ...base, isRead: true } }),
59
+ );
60
+ assert.doesNotMatch(html, /Remove label/);
61
+ });
62
+ });
63
+
64
+ describe("ComfortableRow category presentation", () => {
65
+ const render = (category: ThreadRowData["category"]): string =>
66
+ renderToString(
67
+ createElement(ComfortableRow, {
68
+ thread: { ...base, isRead: true, category },
69
+ }),
70
+ );
71
+
72
+ /** The category Badge's own class signature, so no other span matches. */
73
+ const badgeClass = (html: string): string | undefined =>
74
+ html.match(
75
+ /class="([^"]*rounded-full px-2 py-0\.5 text-2xs font-medium[^"]*)"/,
76
+ )?.[1];
77
+
78
+ it("renders no category badge for personal", () => {
79
+ assert.equal(badgeClass(render("personal")), undefined);
80
+ });
81
+
82
+ it("renders uncategorized as neither personal nor nothing (#45)", () => {
83
+ const uncategorized = render("uncategorized");
84
+ assert.notEqual(uncategorized, render("personal"));
85
+ assert.ok(
86
+ badgeClass(uncategorized),
87
+ "a message the classifier has not reached carries its own badge",
88
+ );
89
+ assert.match(uncategorized, /uncategorized|unclassified/i);
90
+ });
91
+
92
+ it("keeps the uncategorized badge off personal's tone (#45)", () => {
93
+ assert.notEqual(
94
+ categoryTone.uncategorized,
95
+ categoryTone.personal,
96
+ "the tone table must not give unclassified mail personal's colour",
97
+ );
98
+ assert.doesNotMatch(
99
+ badgeClass(render("uncategorized")) ?? "",
100
+ /accent/,
101
+ "the row badge must not render personal's accent tone",
102
+ );
103
+ });
38
104
  });
39
105
 
40
106
  describe("ComfortableRow selection slot", () => {
@@ -5,6 +5,7 @@ import { LIST_ROW_ATTRIBUTE } from "../lib/roving-focus.js";
5
5
  import { categoryTone, type ThreadRowData } from "./app-shell-types.js";
6
6
  import { Avatar } from "./avatar.js";
7
7
  import { Badge } from "./badge.js";
8
+ import { LabelChip } from "./label-chip.js";
8
9
 
9
10
  /** Visible keyboard-focus ring for a row reached by the list's arrow-key cursor. */
10
11
  const ROW_FOCUS_RING =
@@ -166,6 +167,9 @@ export function ComfortableRowTextContent({
166
167
  {thread.category}
167
168
  </Badge>
168
169
  )}
170
+ {thread.labels?.map((label) => (
171
+ <LabelChip key={label.labelId} label={label} className="max-w-20" />
172
+ ))}
169
173
  {badge}
170
174
  </span>
171
175
  </span>
@@ -5,6 +5,7 @@ import {
5
5
  type FilterAccount,
6
6
  flaggedFilterConfig,
7
7
  inboxFilterConfig,
8
+ UNCLASSIFIED_CATEGORY,
8
9
  } from "./filter-presets.js";
9
10
 
10
11
  const accounts: FilterAccount[] = [
@@ -98,3 +99,27 @@ describe("flaggedFilterConfig", () => {
98
99
  assert.equal(flaggedFilterConfig().sources, undefined);
99
100
  });
100
101
  });
102
+
103
+ describe("UNCLASSIFIED_CATEGORY", () => {
104
+ it("is held out of every shipped preset until the server filters it", () => {
105
+ for (const preset of [
106
+ briefFilterConfig(),
107
+ inboxFilterConfig(),
108
+ flaggedFilterConfig(),
109
+ ]) {
110
+ assert.equal(
111
+ preset.categories.some((c) => c.id === UNCLASSIFIED_CATEGORY.id),
112
+ false,
113
+ );
114
+ }
115
+ });
116
+
117
+ it("carries its own label and tone, never personal's (#45)", () => {
118
+ const personal = briefFilterConfig().categories.find(
119
+ (c) => c.id === "personal",
120
+ );
121
+ assert.equal(UNCLASSIFIED_CATEGORY.label, "Unclassified");
122
+ assert.notEqual(UNCLASSIFIED_CATEGORY.label, personal?.label);
123
+ assert.notEqual(UNCLASSIFIED_CATEGORY.tone, personal?.tone);
124
+ });
125
+ });
@@ -27,10 +27,31 @@ export interface FilterPreset {
27
27
  sources?: FilterSheetSource[];
28
28
  }
29
29
 
30
+ /**
31
+ * The Unclassified chip D6 requires, built and storied but not offered yet.
32
+ *
33
+ * This module is a runtime dependency of three live surfaces that filter over
34
+ * the loaded window, so a chip in the list below is a chip a user can click
35
+ * today. Offering it before #306 makes the predicate a server-side `where`
36
+ * would add a fresh instance of the bug this epic exists to fix: a category
37
+ * whose mail sits below the newest page reads as a category with no mail.
38
+ *
39
+ * #306 splices this entry into `MESSAGE_CATEGORIES` after `Personal` — matching
40
+ * `briefCategories`' order — and deletes this constant.
41
+ */
42
+ export const UNCLASSIFIED_CATEGORY: FilterSheetCategory = {
43
+ id: "uncategorized",
44
+ label: "Unclassified",
45
+ tone: "neutral",
46
+ };
47
+
30
48
  /**
31
49
  * Content-type categories, mirroring the `MessageCategory` enum
32
50
  * (@remit/remit-imap) by value. The leading "all" clears the category. Per
33
51
  * message, not per mailbox — so they apply in the brief and an inbox alike.
52
+ *
53
+ * `uncategorized` is absent on purpose and is not folded into `personal`
54
+ * (issue #45) — see `UNCLASSIFIED_CATEGORY`.
34
55
  */
35
56
  const MESSAGE_CATEGORIES: FilterSheetCategory[] = [
36
57
  { id: "all", label: "All", tone: "neutral" },
package/src/index.ts CHANGED
@@ -144,11 +144,13 @@ export {
144
144
  commitBlockedReason,
145
145
  commitLabel,
146
146
  demoFolders,
147
+ demoLabels,
147
148
  demoRule,
148
149
  demoSenderFallbackRule,
149
150
  demoVocabularyRule,
150
151
  type FilterRule,
151
152
  type FolderOption,
153
+ type LabelOption,
152
154
  type MatchOperator,
153
155
  matchJoinWord,
154
156
  matchOperatorLabel,
@@ -221,6 +223,11 @@ export {
221
223
  KeyboardHintBar,
222
224
  type KeyboardHintBarProps,
223
225
  } from "./components/keyboard-hint-bar.js";
226
+ export {
227
+ LabelChip,
228
+ type LabelChipData,
229
+ type LabelChipProps,
230
+ } from "./components/label-chip.js";
224
231
  export { ListItem, type ListItemProps } from "./components/list-item.js";
225
232
  export {
226
233
  type MailAction,
@@ -244,8 +251,11 @@ export { MessageListPane } from "./components/message-list-pane.js";
244
251
  export {
245
252
  type ListState,
246
253
  MessageListEmpty,
254
+ type MessageListEmptyProps,
247
255
  MessageListError,
256
+ type MessageListFilter,
248
257
  MessageListLoading,
258
+ MessageListLoadingMore,
249
259
  } from "./components/message-list-state.js";
250
260
  export {
251
261
  type BriefRowComponent,
@@ -535,6 +545,7 @@ export {
535
545
  type FilterPreset,
536
546
  flaggedFilterConfig,
537
547
  inboxFilterConfig,
548
+ UNCLASSIFIED_CATEGORY,
538
549
  } from "./filter-presets.js";
539
550
  export {
540
551
  buildCidResolver,
@@ -555,6 +566,12 @@ export {
555
566
  sanitizeInlineStyle,
556
567
  sanitizeStyleElementCss,
557
568
  } from "./lib/email-sanitizer.js";
569
+ export {
570
+ isLabelColorValue,
571
+ type LabelColorValue,
572
+ labelColorOptions,
573
+ labelDotClass,
574
+ } from "./lib/label-color.js";
558
575
  export {
559
576
  LIST_ROW_ATTRIBUTE,
560
577
  LIST_ROW_SELECTOR,
@@ -0,0 +1,25 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import {
4
+ isLabelColorValue,
5
+ labelColorOptions,
6
+ labelDotClass,
7
+ } from "./label-color.js";
8
+
9
+ describe("label-color", () => {
10
+ it("every listed option resolves to a dot class", () => {
11
+ for (const color of labelColorOptions) {
12
+ assert.ok(labelDotClass[color]);
13
+ }
14
+ });
15
+
16
+ it("recognizes every named color value", () => {
17
+ for (const color of labelColorOptions) {
18
+ assert.ok(isLabelColorValue(color));
19
+ }
20
+ });
21
+
22
+ it("rejects a value outside the named set", () => {
23
+ assert.equal(isLabelColorValue("Chartreuse"), false);
24
+ });
25
+ });
@@ -0,0 +1,46 @@
1
+ /**
2
+ * A user-picked label color (RFC 030, issue #26). Unlike the app's status
3
+ * tones (positive/warning/danger/…), a label's color carries no meaning the
4
+ * design system assigns — the user picked it to tell their own labels apart —
5
+ * so it renders from the literal Tailwind palette rather than a semantic
6
+ * token.
7
+ */
8
+ export type LabelColorValue =
9
+ | "Default"
10
+ | "Red"
11
+ | "Orange"
12
+ | "Yellow"
13
+ | "Green"
14
+ | "Teal"
15
+ | "Blue"
16
+ | "Purple"
17
+ | "Gray";
18
+
19
+ /** Solid dot color for a label swatch or chip. */
20
+ export const labelDotClass: Record<LabelColorValue, string> = {
21
+ Default: "bg-fg-subtle",
22
+ Red: "bg-red-500",
23
+ Orange: "bg-orange-500",
24
+ Yellow: "bg-yellow-500",
25
+ Green: "bg-green-500",
26
+ Teal: "bg-teal-500",
27
+ Blue: "bg-blue-500",
28
+ Purple: "bg-purple-500",
29
+ Gray: "bg-gray-500",
30
+ };
31
+
32
+ /** Every color a label can be assigned, in picker order. */
33
+ export const labelColorOptions: LabelColorValue[] = [
34
+ "Default",
35
+ "Red",
36
+ "Orange",
37
+ "Yellow",
38
+ "Green",
39
+ "Teal",
40
+ "Blue",
41
+ "Purple",
42
+ "Gray",
43
+ ];
44
+
45
+ export const isLabelColorValue = (value: string): value is LabelColorValue =>
46
+ Object.hasOwn(labelDotClass, value);
@@ -51,3 +51,27 @@ describe("story files use the viewport globals pattern (#68)", () => {
51
51
  assert.deepEqual(offenders, []);
52
52
  });
53
53
  });
54
+
55
+ /**
56
+ * Storybook composes decorators story → component → global, so a story-level
57
+ * `decorators: []` adds nothing. It reads as "this story opts out of the meta's
58
+ * frame" and silently does the opposite, which clipped a story down to half its
59
+ * content. A story that needs a different frame carries the frame itself.
60
+ */
61
+ describe("story files never claim to shed a meta decorator", () => {
62
+ const roots = [
63
+ here,
64
+ resolve(here, "../../workbench/src"),
65
+ resolve(here, "../../web-client/src"),
66
+ ];
67
+
68
+ it("never uses an empty story-level decorators array", () => {
69
+ const offenders = roots
70
+ .flatMap((root) => storyFiles(root))
71
+ .filter((file) =>
72
+ /decorators:\s*\[\s*\]/.test(readFileSync(file, "utf8")),
73
+ )
74
+ .map((file) => relative(here, file));
75
+ assert.deepEqual(offenders, []);
76
+ });
77
+ });