@remit/ui 0.0.13 → 0.0.15

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 (28) hide show
  1. package/package.json +1 -1
  2. package/src/components/button.render.test.ts +29 -0
  3. package/src/components/button.tsx +5 -1
  4. package/src/components/folder-role.test.ts +72 -0
  5. package/src/components/folder-role.tsx +59 -0
  6. package/src/components/mobile-search-view.render.test.ts +78 -0
  7. package/src/components/mobile-search-view.stories.tsx +117 -2
  8. package/src/components/mobile-search-view.tsx +20 -1
  9. package/src/components/primitives.stories.tsx +6 -0
  10. package/src/components/progress-bar.render.test.ts +46 -0
  11. package/src/components/progress-bar.stories.tsx +35 -0
  12. package/src/components/progress-bar.tsx +62 -0
  13. package/src/components/search-result-row.tsx +23 -0
  14. package/src/components/search-results.render.test.ts +181 -0
  15. package/src/components/search-results.stories.tsx +165 -28
  16. package/src/components/search-results.tsx +101 -5
  17. package/src/components/selection-top-bar.render.test.ts +167 -17
  18. package/src/components/selection-top-bar.stories.tsx +106 -16
  19. package/src/components/selection-top-bar.tsx +117 -64
  20. package/src/components/spam-results-offer.render.test.ts +26 -0
  21. package/src/components/spam-results-offer.stories.tsx +41 -0
  22. package/src/components/spam-results-offer.tsx +52 -0
  23. package/src/components/swipeable-row.render.test.ts +29 -0
  24. package/src/components/swipeable-row.tsx +42 -2
  25. package/src/components/touch-list-body.render.test.ts +25 -0
  26. package/src/components/touch-list-body.stories.tsx +15 -0
  27. package/src/components/touch-list.tsx +26 -15
  28. package/src/index.ts +15 -0
@@ -0,0 +1,26 @@
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 { SpamResultsOffer } from "./spam-results-offer.js";
6
+
7
+ const noop = () => {};
8
+
9
+ describe("SpamResultsOffer", () => {
10
+ it("states the count and offers a way into Spam", () => {
11
+ const html = renderToString(
12
+ createElement(SpamResultsOffer, { count: 3, onScopeToSpam: noop }),
13
+ );
14
+ assert.match(html, />3</);
15
+ assert.match(html, /results from Spam/);
16
+ assert.match(html, /View them/);
17
+ });
18
+
19
+ it("reads in the singular for one match", () => {
20
+ const html = renderToString(
21
+ createElement(SpamResultsOffer, { count: 1, onScopeToSpam: noop }),
22
+ );
23
+ assert.match(html, /result from Spam/);
24
+ assert.doesNotMatch(html, /results from Spam/);
25
+ });
26
+ });
@@ -0,0 +1,41 @@
1
+ import type { Decorator, Meta, StoryObj } from "@storybook/react";
2
+ import { SpamResultsOffer } from "./spam-results-offer.js";
3
+
4
+ /** Frames the offer at the width of the list pane it sits at the top of. */
5
+ const listPaneFrame: Decorator = (Story) => (
6
+ <div
7
+ className="overflow-hidden rounded-lg border border-line bg-canvas"
8
+ style={{ width: 360 }}
9
+ >
10
+ <Story />
11
+ </div>
12
+ );
13
+
14
+ const meta: Meta<typeof SpamResultsOffer> = {
15
+ title: "Components/SpamResultsOffer",
16
+ component: SpamResultsOffer,
17
+ parameters: { layout: "centered" },
18
+ decorators: [listPaneFrame],
19
+ };
20
+ export default meta;
21
+
22
+ type Story = StoryObj<typeof SpamResultsOffer>;
23
+
24
+ /**
25
+ * Spam matches a global search held out of its results. Deliberately quiet —
26
+ * an offer, not a warning — and the action scopes the search to Spam rather
27
+ * than opening a view of its own.
28
+ */
29
+ export const Default: Story = {
30
+ args: { count: 3, onScopeToSpam: () => {} },
31
+ };
32
+
33
+ /** One match reads in the singular. */
34
+ export const SingleResult: Story = {
35
+ args: { count: 1, onScopeToSpam: () => {} },
36
+ };
37
+
38
+ /** Large counts stay on one line; the figure is tabular so it does not jitter. */
39
+ export const ManyResults: Story = {
40
+ args: { count: 128, onScopeToSpam: () => {} },
41
+ };
@@ -0,0 +1,52 @@
1
+ import { Banner } from "./banner.js";
2
+ import { Button } from "./button.js";
3
+
4
+ export interface SpamResultsOfferProps {
5
+ /** How many matches the search found in Spam. */
6
+ count: number;
7
+ /**
8
+ * Scope the search to Spam. This is a shortcut to the state reached by
9
+ * navigating to Spam with the query carried over — the same scoped search,
10
+ * with the same `in:spam` chip — not a separate result mode.
11
+ */
12
+ onScopeToSpam: () => void;
13
+ }
14
+
15
+ const plural = (n: number): string => (n === 1 ? "result" : "results");
16
+
17
+ /**
18
+ * Spam matches held out of a global search, offered rather than mixed in. Spam
19
+ * is the one folder a search that reaches everywhere does not inline, because
20
+ * the whole point of the folder is that its contents are unwanted until asked
21
+ * for. Taking the offer scopes the search to Spam.
22
+ *
23
+ * Quiet on purpose: this is an offer, not a warning. Presentational — the
24
+ * caller owns what "scope to spam" does.
25
+ */
26
+ export function SpamResultsOffer({
27
+ count,
28
+ onScopeToSpam,
29
+ }: SpamResultsOfferProps) {
30
+ return (
31
+ <Banner
32
+ tone="info"
33
+ variant="soft"
34
+ className="items-center justify-between gap-3 rounded-none border-b border-line"
35
+ >
36
+ <div className="flex items-center justify-between gap-3">
37
+ <p className="min-w-0 text-xs text-fg-muted">
38
+ <span className="font-semibold text-fg tabular-nums">{count}</span>{" "}
39
+ {`${plural(count)} from Spam`}
40
+ </p>
41
+ <Button
42
+ variant="ghost"
43
+ size="sm"
44
+ onClick={onScopeToSpam}
45
+ className="shrink-0 text-accent"
46
+ >
47
+ View them
48
+ </Button>
49
+ </div>
50
+ </Banner>
51
+ );
52
+ }
@@ -103,4 +103,33 @@ describe("SwipeableRow", () => {
103
103
  assert.doesNotMatch(html, /<a /);
104
104
  assert.match(html, /<button[^>]*>/);
105
105
  });
106
+
107
+ it("gives the row checkbox semantics while in selection mode", () => {
108
+ const checked = render("none", { selectionMode: true, checked: true });
109
+ assert.match(checked, /role="checkbox"/);
110
+ assert.match(checked, /aria-checked="true"/);
111
+
112
+ const unchecked = render("none", { selectionMode: true, checked: false });
113
+ assert.match(unchecked, /aria-checked="false"/);
114
+ });
115
+
116
+ it("does not put checkbox semantics on the outer row outside selection mode", () => {
117
+ // Outside selection mode the row's own open control (the outer button)
118
+ // stays a plain button — only the nested leading-avatar toggle carries
119
+ // checkbox semantics, asserted separately below.
120
+ const html = render("none");
121
+ const outerTag = html.match(
122
+ /^<div class="relative overflow-hidden"><button type="button"[^>]*>/,
123
+ )?.[0];
124
+ assert.ok(outerTag, "outer row button found");
125
+ assert.doesNotMatch(outerTag as string, /role="checkbox"/);
126
+ });
127
+
128
+ it("renders the leading avatar as a focusable checkbox-role toggle outside selection mode", () => {
129
+ const html = render("none");
130
+ assert.match(
131
+ html,
132
+ /role="checkbox"[^>]*aria-label="Select message from Alex Rivera"/,
133
+ );
134
+ });
106
135
  });
@@ -2,8 +2,8 @@ import { Check, Mail, MailOpen, Trash2 } from "lucide-react";
2
2
  import { useRef, useState } from "react";
3
3
  import { cn } from "../lib/cn.js";
4
4
  import type { ThreadRowData } from "./app-shell-types.js";
5
+ import { Avatar } from "./avatar.js";
5
6
  import {
6
- ComfortableRowBody,
7
7
  ComfortableRowTextContent,
8
8
  comfortableRowClass,
9
9
  } from "./message-row.js";
@@ -192,6 +192,14 @@ export function SwipeableRow({
192
192
  transition: dragX === null ? "transform 150ms ease" : "none",
193
193
  minHeight: 44,
194
194
  };
195
+ const unread = !thread.isRead;
196
+
197
+ // Stops the row's own pointer-gesture handlers (long-press, swipe axis
198
+ // detection) from also firing for a tap that started on the nested avatar
199
+ // toggle — the row and the toggle are two separate controls sharing the
200
+ // same leading 28px slot.
201
+ const stopRowGesture = (e: React.PointerEvent) => e.stopPropagation();
202
+
195
203
  const body = selectionMode ? (
196
204
  <>
197
205
  <span
@@ -207,7 +215,36 @@ export function SwipeableRow({
207
215
  <ComfortableRowTextContent thread={thread} />
208
216
  </>
209
217
  ) : (
210
- <ComfortableRowBody thread={thread} />
218
+ <>
219
+ {unread && (
220
+ <span className="absolute left-1.5 top-1/2 size-1.5 -translate-y-1/2 rounded-full bg-accent" />
221
+ )}
222
+ {/*
223
+ * Tappable, focusable entry point into selection mode — long-press is
224
+ * never the only way in. Nested inside the row's own open control
225
+ * (button or, via linkComponent, an anchor); mirrors the leading-slot
226
+ * toggle already shipped in the web client's row.
227
+ */}
228
+ {/* biome-ignore lint/a11y/useSemanticElements: a native <input type="checkbox"> can't host the Avatar as its visible content; role="checkbox" on a button mirrors the row-checkbox pattern already shipped in MessageListItem.tsx */}
229
+ <button
230
+ type="button"
231
+ role="checkbox"
232
+ aria-checked={checked}
233
+ aria-label={`Select message from ${thread.fromName}`}
234
+ onPointerDown={stopRowGesture}
235
+ onPointerMove={stopRowGesture}
236
+ onPointerUp={stopRowGesture}
237
+ onClick={(e) => {
238
+ e.preventDefault();
239
+ e.stopPropagation();
240
+ onLongPress();
241
+ }}
242
+ className="inline-flex size-7 shrink-0 items-center justify-center rounded-full"
243
+ >
244
+ <Avatar name={thread.fromName} email={thread.fromEmail} size="sm" />
245
+ </button>
246
+ <ComfortableRowTextContent thread={thread} />
247
+ </>
211
248
  );
212
249
 
213
250
  return (
@@ -251,8 +288,11 @@ export function SwipeableRow({
251
288
  children: body,
252
289
  })
253
290
  ) : (
291
+ // biome-ignore lint/a11y/useAriaPropsSupportedByRole: role is only ever "checkbox" (aria-checked's owning role) when selectionMode is true; the ternaries are linked, biome can't see that statically
254
292
  <button
255
293
  type="button"
294
+ role={selectionMode ? "checkbox" : undefined}
295
+ aria-checked={selectionMode ? checked : undefined}
256
296
  onPointerDown={onPointerDown}
257
297
  onPointerMove={onPointerMove}
258
298
  onPointerUp={onPointerUp}
@@ -63,4 +63,29 @@ describe("TouchListBody", () => {
63
63
  const html = renderToString(createElement(TouchListBody, baseProps));
64
64
  assert.match(html, /Pull to refresh/);
65
65
  });
66
+
67
+ it("dims and suppresses taps on every row while busy", () => {
68
+ const html = renderToString(
69
+ createElement(TouchListBody, {
70
+ ...baseProps,
71
+ selectionMode: true,
72
+ checkedIds: new Set(["t1", "t2"]),
73
+ busy: true,
74
+ }),
75
+ );
76
+ const dimmed = (html.match(/pointer-events-none opacity-50/g) ?? []).length;
77
+ assert.equal(dimmed, 2, "both seeded rows are dimmed");
78
+ });
79
+
80
+ it("hides pull to refresh while busy", () => {
81
+ const html = renderToString(
82
+ createElement(TouchListBody, { ...baseProps, busy: true }),
83
+ );
84
+ assert.doesNotMatch(html, /Pull to refresh/);
85
+ });
86
+
87
+ it("renders rows undimmed when not busy", () => {
88
+ const html = renderToString(createElement(TouchListBody, baseProps));
89
+ assert.doesNotMatch(html, /pointer-events-none/);
90
+ });
66
91
  });
@@ -93,3 +93,18 @@ export const SelectionModeAllChecked: Story = {
93
93
  export const SelectionModeNoneChecked: Story = {
94
94
  args: { selectionMode: true, checkedIds: new Set<string>() },
95
95
  };
96
+
97
+ /**
98
+ * A bulk delete is running against the checked rows: they stay checked but
99
+ * dim, and stop responding to taps — no more opening a message that's
100
+ * mid-delete. Pairs with `SelectionTopBar`'s `DeletingWithProgress` story.
101
+ */
102
+ export const SelectionModeBusy: Story = {
103
+ args: {
104
+ selectionMode: true,
105
+ checkedIds: new Set(
106
+ sections.flatMap((section) => section.threads.map((t) => t.id)),
107
+ ),
108
+ busy: true,
109
+ },
110
+ };
@@ -14,6 +14,7 @@ export function TouchListBody({
14
14
  onOpenThread,
15
15
  onRefresh,
16
16
  refreshing,
17
+ busy = false,
17
18
  }: {
18
19
  sections: ThreadSection[];
19
20
  selectedThreadId?: string;
@@ -25,6 +26,12 @@ export function TouchListBody({
25
26
  onOpenThread: (id: string) => void;
26
27
  onRefresh: () => void;
27
28
  refreshing: boolean;
29
+ /**
30
+ * A bulk operation (e.g. delete) is running against the checked set. Rows
31
+ * dim and stop responding to taps instead of sitting normal, undimmed and
32
+ * still tappable while a count above them claims they're being deleted.
33
+ */
34
+ busy?: boolean;
28
35
  }) {
29
36
  // Local copy so the mock can act on a swipe: delete removes the row,
30
37
  // toggle-read flips its state. The live client owns real mutation.
@@ -56,24 +63,28 @@ export function TouchListBody({
56
63
  )}
57
64
  <div className="divide-y divide-line">
58
65
  {items.map((thread) => (
59
- <SwipeableRow
66
+ <div
60
67
  key={thread.id}
61
- thread={thread}
62
- selectionMode={selectionMode}
63
- checked={checkedIds.has(thread.id)}
64
- active={thread.id === selectedThreadId}
65
- peek={peek?.id === thread.id ? peek.side : "none"}
66
- onPeek={(next) =>
67
- setPeek(next === "none" ? null : { id: thread.id, side: next })
68
- }
69
- onToggleCheck={() => onToggleCheck(thread.id)}
70
- onLongPress={() => onEnterSelection(thread.id)}
71
- onOpen={() => onOpenThread(thread.id)}
72
- onAct={(side) => act(thread.id, side)}
73
- />
68
+ className={busy ? "pointer-events-none opacity-50" : undefined}
69
+ >
70
+ <SwipeableRow
71
+ thread={thread}
72
+ selectionMode={selectionMode}
73
+ checked={checkedIds.has(thread.id)}
74
+ active={thread.id === selectedThreadId}
75
+ peek={peek?.id === thread.id ? peek.side : "none"}
76
+ onPeek={(next) =>
77
+ setPeek(next === "none" ? null : { id: thread.id, side: next })
78
+ }
79
+ onToggleCheck={() => onToggleCheck(thread.id)}
80
+ onLongPress={() => onEnterSelection(thread.id)}
81
+ onOpen={() => onOpenThread(thread.id)}
82
+ onAct={(side) => act(thread.id, side)}
83
+ />
84
+ </div>
74
85
  ))}
75
86
  </div>
76
- {!selectionMode && !refreshing && (
87
+ {!selectionMode && !refreshing && !busy && (
77
88
  <button
78
89
  type="button"
79
90
  onClick={onRefresh}
package/src/index.ts CHANGED
@@ -121,7 +121,10 @@ export {
121
121
  export {
122
122
  canonicalRoleLabel,
123
123
  type FolderRole,
124
+ isVirtualFolderRole,
125
+ provenanceFolderLabel,
124
126
  providerLeaf,
127
+ type ResultFolder,
125
128
  roleIcon,
126
129
  } from "./components/folder-role.js";
127
130
  export {
@@ -232,6 +235,10 @@ export {
232
235
  type PopoverMenuItem,
233
236
  type PopoverMenuProps,
234
237
  } from "./components/popover-menu.js";
238
+ export {
239
+ ProgressBar,
240
+ type ProgressBarProps,
241
+ } from "./components/progress-bar.js";
235
242
  export {
236
243
  PullToRefresh,
237
244
  type PullToRefreshProps,
@@ -304,9 +311,11 @@ export {
304
311
  type SearchResultTone,
305
312
  } from "./components/search-result-row.js";
306
313
  export {
314
+ partitionSpamResults,
307
315
  type SearchResultSection,
308
316
  SearchResults,
309
317
  type SearchResultsProps,
318
+ type SearchScope,
310
319
  } from "./components/search-results.js";
311
320
  export {
312
321
  SearchChipRow,
@@ -331,6 +340,8 @@ export {
331
340
  export { Select, type SelectProps } from "./components/select.js";
332
341
  export {
333
342
  SelectionTopBar,
343
+ type SelectionTopBarNotice,
344
+ type SelectionTopBarNoticeAction,
334
345
  type SelectionTopBarProps,
335
346
  } from "./components/selection-top-bar.js";
336
347
  export {
@@ -358,6 +369,10 @@ export {
358
369
  SlidePanel,
359
370
  type SlidePanelProps,
360
371
  } from "./components/slide-panel.js";
372
+ export {
373
+ SpamResultsOffer,
374
+ type SpamResultsOfferProps,
375
+ } from "./components/spam-results-offer.js";
361
376
  export {
362
377
  commitPeek,
363
378
  SwipeableRow,