@remit/ui 0.0.77 → 0.0.79

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.77",
3
+ "version": "0.0.79",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "src"
@@ -1,11 +1,13 @@
1
1
  import type { Meta, StoryObj } from "@storybook/react";
2
- import { Bug, SquarePen } from "lucide-react";
3
2
  import { useState } from "react";
4
3
  import { AppTopBar } from "./app-top-bar.js";
5
- import { Avatar } from "./avatar.js";
6
- import { Button } from "./button.js";
7
- import { type SearchChip, SearchChipInput } from "./search-chip-input.js";
4
+ import { SearchChipInput } from "./search-chip-input.js";
8
5
 
6
+ /**
7
+ * The bar's geometry. What fills it — which actions, in what order, with what
8
+ * wording — is `ShellTopBar`, which is what the app and the shell prototype
9
+ * both mount; these stories show only the row the slots sit in.
10
+ */
9
11
  const meta: Meta<typeof AppTopBar> = {
10
12
  title: "Mail/AppTopBar",
11
13
  component: AppTopBar,
@@ -15,169 +17,64 @@ export default meta;
15
17
 
16
18
  type Story = StoryObj<typeof AppTopBar>;
17
19
 
18
- const Actions = () => (
19
- <>
20
- <Button
21
- variant="ghost"
22
- size="sm"
23
- icon={<SquarePen className="size-4" />}
24
- title="Compose"
25
- aria-label="Compose"
26
- />
27
- <Button
28
- variant="ghost"
29
- size="sm"
30
- icon={<Bug className="size-4" />}
31
- title="Report a bug"
32
- aria-label="Report a bug"
33
- />
34
- <Avatar name="Matthijs van Henten" email="mvh@example.com" size="sm" />
35
- </>
20
+ const Slot = ({ label }: { label: string }) => (
21
+ <div className="rounded border border-dashed border-line px-2 py-1 text-2xs text-fg-subtle">
22
+ {label}
23
+ </div>
36
24
  );
37
25
 
38
- const SCOPE: SearchChip = { id: "in:spam", label: "in:spam", tone: "scope" };
39
-
40
- /**
41
- * The placeholders the app pairs with each scope state. Only the unscoped brief
42
- * may claim to search all mail; a scoped view says so, and a mailbox route
43
- * whose name has not loaded yet gets neutral wording rather than a placeholder
44
- * asserting the wrong scope.
45
- */
46
- const PLACEHOLDER = {
47
- global: "Search all mail",
48
- pending: "Search mail",
49
- scoped: "Search this folder",
50
- } as const;
51
-
52
- const Bar = ({
53
- initialChips = [],
54
- placeholder = PLACEHOLDER.global,
55
- }: {
56
- initialChips?: SearchChip[];
57
- placeholder?: string;
58
- }) => {
59
- const [chips, setChips] = useState<SearchChip[]>(initialChips);
26
+ const Field = () => {
60
27
  const [value, setValue] = useState("");
61
28
  return (
62
- <AppTopBar
63
- actions={<Actions />}
64
- search={
65
- <SearchChipInput
66
- size="lg"
67
- chips={chips}
68
- onRemoveChip={(id) => setChips((cs) => cs.filter((c) => c.id !== id))}
69
- value={value}
70
- onChange={setValue}
71
- onClear={() => {
72
- setValue("");
73
- setChips([]);
74
- }}
75
- onClearQuery={() => setValue("")}
76
- globalFocusKey={false}
77
- placeholder={placeholder}
78
- />
79
- }
29
+ <SearchChipInput
30
+ size="lg"
31
+ value={value}
32
+ onChange={setValue}
33
+ onClear={() => setValue("")}
34
+ onClearQuery={() => setValue("")}
35
+ globalFocusKey={false}
36
+ placeholder="Search all mail"
80
37
  />
81
38
  );
82
39
  };
83
40
 
84
- /** Over the panes it spans, so the arrangement reads the way it will in the app. */
85
- const WithPanes = ({ children }: { children: React.ReactNode }) => (
86
- <div className="flex h-96 flex-col bg-canvas">
87
- {children}
88
- <div className="flex min-h-0 flex-1">
89
- <div className="w-56 shrink-0 border-r border-line bg-surface p-3 text-xs text-fg-muted">
90
- Nav — under the bar, like every other pane
91
- </div>
92
- <div className="w-72 shrink-0 border-r border-line bg-surface p-3 text-xs text-fg-muted">
93
- Message list
94
- </div>
95
- <div className="min-w-0 flex-1 p-3 text-xs text-fg-muted">
96
- Message pane — its own toolbar lives here, under the bar
97
- </div>
98
- </div>
99
- </div>
100
- );
101
-
102
- /**
103
- * The daily brief's state: search unscoped, nothing narrowing it. No chip, and
104
- * the only placeholder allowed to claim it searches all mail — which it now
105
- * genuinely does, across every folder of every account.
106
- */
107
- export const Unscoped: Story = {
108
- render: () => <Bar />,
109
- };
110
-
111
- /**
112
- * A narrowing scope in the bar, tinted to mark it as the view the user is in
113
- * rather than a filter they typed. Removing it widens the search again — a
114
- * navigation back to the brief, not an edit of the text, because the chip
115
- * mirrors the route.
116
- *
117
- * The placeholder narrows with the chip. A scoped bar reading "Search all mail"
118
- * is a state the app never produces.
119
- */
120
- export const Scoped: Story = {
121
- render: () => <Bar initialChips={[SCOPE]} placeholder={PLACEHOLDER.scoped} />,
122
- };
123
-
124
- /**
125
- * The third scope state: a mailbox route whose name has not resolved yet. The
126
- * list underneath is already narrowed, so the bar must not claim to search
127
- * everything — but a chip reading a raw uuid is worse than no chip, so it shows
128
- * none and falls back to neutral wording until the name arrives.
129
- */
130
- export const ScopePending: Story = {
131
- render: () => <Bar placeholder={PLACEHOLDER.pending} />,
132
- };
133
-
134
- /**
135
- * The virtual collections scope the bar too, and their chips read as whatever
136
- * describes the collection. Flagged is a marker on the mail rather than a
137
- * place, so it chips `is:starred`; the outbox is a place mail sits in and keeps
138
- * the `in:` form.
139
- */
140
- export const ScopedToFlagged: Story = {
41
+ /** Leading · search · actions. The field is the only slot that grows. */
42
+ export const Slots: Story = {
141
43
  render: () => (
142
- <Bar
143
- initialChips={[{ id: "is:starred", label: "is:starred", tone: "scope" }]}
144
- placeholder={PLACEHOLDER.scoped}
44
+ <AppTopBar
45
+ leading={<Slot label="leading" />}
46
+ search={<Field />}
47
+ actions={<Slot label="actions" />}
145
48
  />
146
49
  ),
147
50
  };
148
51
 
149
- export const ScopedToOutbox: Story = {
150
- render: () => (
151
- <Bar
152
- initialChips={[{ id: "in:outbox", label: "in:outbox", tone: "scope" }]}
153
- placeholder={PLACEHOLDER.scoped}
154
- />
155
- ),
52
+ /** With nothing but the field, the bar is still the page's one search surface. */
53
+ export const SearchOnly: Story = {
54
+ render: () => <AppTopBar search={<Field />} />,
156
55
  };
157
56
 
158
- /** The arrangement: one bar across the top of the shell, over the nav, the list
159
- * and the message pane alike. */
57
+ /** One row across the top of the shell, over the nav, the list and the message
58
+ * pane alike. */
160
59
  export const OverTheLayout: Story = {
161
60
  render: () => (
162
- <WithPanes>
163
- <Bar initialChips={[SCOPE]} placeholder={PLACEHOLDER.scoped} />
164
- </WithPanes>
165
- ),
166
- };
167
-
168
- export const SearchOnly: Story = {
169
- render: () => (
170
- <AppTopBar
171
- search={
172
- <SearchChipInput
173
- size="lg"
174
- value=""
175
- onChange={() => undefined}
176
- onClear={() => undefined}
177
- globalFocusKey={false}
178
- placeholder="Search all mail"
179
- />
180
- }
181
- />
61
+ <div className="flex h-96 flex-col bg-canvas">
62
+ <AppTopBar
63
+ leading={<Slot label="leading" />}
64
+ search={<Field />}
65
+ actions={<Slot label="actions" />}
66
+ />
67
+ <div className="flex min-h-0 flex-1">
68
+ <div className="w-56 shrink-0 border-r border-line bg-surface p-3 text-xs text-fg-muted">
69
+ Nav — under the bar, like every other pane
70
+ </div>
71
+ <div className="w-72 shrink-0 border-r border-line bg-surface p-3 text-xs text-fg-muted">
72
+ Message list
73
+ </div>
74
+ <div className="min-w-0 flex-1 p-3 text-xs text-fg-muted">
75
+ Message pane — its own toolbar lives here, under the bar
76
+ </div>
77
+ </div>
78
+ </div>
182
79
  ),
183
80
  };
@@ -0,0 +1,95 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import { createElement } from "react";
4
+ import { renderToString } from "react-dom/server";
5
+ import {
6
+ ShellTopBar,
7
+ type ShellTopBarProps,
8
+ type ShellTopBarSearch,
9
+ } from "./shell-top-bar.js";
10
+
11
+ const search = (
12
+ overrides: Partial<ShellTopBarSearch> = {},
13
+ ): ShellTopBarSearch => ({
14
+ value: "",
15
+ scope: "global",
16
+ onChange: () => undefined,
17
+ onClear: () => undefined,
18
+ onClearQuery: () => undefined,
19
+ ...overrides,
20
+ });
21
+
22
+ const render = (overrides: Partial<ShellTopBarProps> = {}): string =>
23
+ renderToString(
24
+ createElement(ShellTopBar, {
25
+ search: search(),
26
+ onCompose: () => undefined,
27
+ onReportBug: () => undefined,
28
+ onOpenSettings: () => undefined,
29
+ account: createElement("div", { "data-testid": "account" }, "account"),
30
+ ...overrides,
31
+ }),
32
+ );
33
+
34
+ describe("ShellTopBar", () => {
35
+ it("carries compose, bug report, settings and the account, in that order", () => {
36
+ const html = render();
37
+ const compose = html.indexOf('aria-label="Compose"');
38
+ const bug = html.indexOf('aria-label="Report a bug"');
39
+ const settings = html.indexOf('aria-label="Settings"');
40
+ const account = html.indexOf('data-testid="account"');
41
+ assert.ok(compose > -1 && bug > -1 && settings > -1 && account > -1);
42
+ assert.ok(
43
+ compose < bug && bug < settings && settings < account,
44
+ "actions render in reading order",
45
+ );
46
+ });
47
+
48
+ it("carries exactly the four actions and nothing else", () => {
49
+ const labels = [...render().matchAll(/aria-label="([^"]+)"/g)].map(
50
+ (match) => match[1],
51
+ );
52
+ assert.deepEqual(labels, [
53
+ "Search mail",
54
+ "Compose",
55
+ "Report a bug",
56
+ "Settings",
57
+ ]);
58
+ });
59
+
60
+ it("words the compose tooltip around the host's bare key", () => {
61
+ assert.match(render({ composeShortcut: "c" }), /title="Compose \(c\)"/);
62
+ });
63
+
64
+ it("names the action alone when the host binds no key to it", () => {
65
+ assert.match(render(), /title="Compose"/);
66
+ });
67
+
68
+ it("only claims to search all mail when nothing narrows it", () => {
69
+ assert.match(render(), /placeholder="Search all mail"/);
70
+ });
71
+
72
+ it("says which folder a scoped view searches", () => {
73
+ assert.match(
74
+ render({ search: search({ scope: "scoped" }) }),
75
+ /placeholder="Search this folder"/,
76
+ );
77
+ });
78
+
79
+ it("carries the route's scope as a chip and drops the claim to search all mail", () => {
80
+ const html = render({
81
+ search: search({
82
+ scope: "scoped",
83
+ chips: [{ id: "in:spam", label: "in:spam", tone: "scope" }],
84
+ }),
85
+ });
86
+ assert.match(html, /in:spam/);
87
+ assert.doesNotMatch(html, /Search all mail/);
88
+ });
89
+
90
+ it("falls back to neutral wording while a mailbox name is still loading", () => {
91
+ const html = render({ search: search({ scope: "pending" }) });
92
+ assert.match(html, /placeholder="Search mail"/);
93
+ assert.doesNotMatch(html, /Search all mail/);
94
+ });
95
+ });
@@ -0,0 +1,132 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import { useState } from "react";
3
+ import { Avatar } from "./avatar.js";
4
+ import type { SearchChip } from "./search-chip-input.js";
5
+ import { type ShellSearchScope, ShellTopBar } from "./shell-top-bar.js";
6
+
7
+ const meta: Meta<typeof ShellTopBar> = {
8
+ title: "Mail/ShellTopBar",
9
+ component: ShellTopBar,
10
+ parameters: { layout: "fullscreen" },
11
+ };
12
+ export default meta;
13
+
14
+ type Story = StoryObj<typeof ShellTopBar>;
15
+
16
+ const SCOPE: SearchChip = { id: "in:spam", label: "in:spam", tone: "scope" };
17
+
18
+ const Account = () => (
19
+ <button type="button" aria-label="Account">
20
+ <Avatar name="Matthijs van Henten" email="mvh@example.com" size="sm" />
21
+ </button>
22
+ );
23
+
24
+ const Bar = ({
25
+ initialChips,
26
+ scope = "global",
27
+ }: {
28
+ initialChips?: SearchChip[];
29
+ scope?: ShellSearchScope;
30
+ }) => {
31
+ const [chips, setChips] = useState<SearchChip[] | undefined>(initialChips);
32
+ const [value, setValue] = useState("");
33
+ return (
34
+ <ShellTopBar
35
+ search={{
36
+ value,
37
+ scope: chips?.length ? scope : "global",
38
+ chips,
39
+ onChange: setValue,
40
+ onClear: () => {
41
+ setValue("");
42
+ setChips(undefined);
43
+ },
44
+ onClearQuery: () => setValue(""),
45
+ onRemoveChip: () => setChips(undefined),
46
+ }}
47
+ onCompose={() => undefined}
48
+ onReportBug={() => undefined}
49
+ onOpenSettings={() => undefined}
50
+ composeShortcut="c"
51
+ account={<Account />}
52
+ />
53
+ );
54
+ };
55
+
56
+ /**
57
+ * The daily brief's state: search unscoped, nothing narrowing it, and the only
58
+ * placeholder allowed to claim it searches all mail — which it genuinely does,
59
+ * across every folder of every account.
60
+ */
61
+ export const Unscoped: Story = {
62
+ render: () => <Bar />,
63
+ };
64
+
65
+ /**
66
+ * A narrowing scope in the bar, tinted to mark it as the view the user is in
67
+ * rather than a filter they typed. Removing it widens the search again — a
68
+ * navigation back to the brief, not an edit of the text, because the chip
69
+ * mirrors the route. The placeholder narrows with the chip.
70
+ */
71
+ export const Scoped: Story = {
72
+ render: () => <Bar initialChips={[SCOPE]} scope="scoped" />,
73
+ };
74
+
75
+ /**
76
+ * A mailbox route whose name has not resolved yet. The list underneath is
77
+ * already narrowed, so the bar must not claim to search everything — and a chip
78
+ * reading a raw uuid is worse than no chip, so it shows none and falls back to
79
+ * neutral wording until the name arrives.
80
+ */
81
+ export const ScopePending: Story = {
82
+ render: () => (
83
+ <ShellTopBar
84
+ search={{
85
+ value: "",
86
+ scope: "pending",
87
+ onChange: () => undefined,
88
+ onClear: () => undefined,
89
+ onClearQuery: () => undefined,
90
+ }}
91
+ onCompose={() => undefined}
92
+ onReportBug={() => undefined}
93
+ onOpenSettings={() => undefined}
94
+ composeShortcut="c"
95
+ account={<Account />}
96
+ />
97
+ ),
98
+ };
99
+
100
+ /**
101
+ * The virtual collections scope the bar too, and their chips read as whatever
102
+ * describes the collection. Flagged is a marker on the mail rather than a
103
+ * place, so it chips `is:starred`.
104
+ */
105
+ export const ScopedToFlagged: Story = {
106
+ render: () => (
107
+ <Bar
108
+ initialChips={[{ id: "is:starred", label: "is:starred", tone: "scope" }]}
109
+ scope="scoped"
110
+ />
111
+ ),
112
+ };
113
+
114
+ /** The arrangement: one bar across the top of the shell, over every pane. */
115
+ export const OverTheLayout: Story = {
116
+ render: () => (
117
+ <div className="flex h-96 flex-col bg-canvas">
118
+ <Bar initialChips={[SCOPE]} scope="scoped" />
119
+ <div className="flex min-h-0 flex-1">
120
+ <div className="w-56 shrink-0 border-r border-line bg-surface p-3 text-xs text-fg-muted">
121
+ Nav — under the bar, like every other pane
122
+ </div>
123
+ <div className="w-72 shrink-0 border-r border-line bg-surface p-3 text-xs text-fg-muted">
124
+ Message list
125
+ </div>
126
+ <div className="min-w-0 flex-1 p-3 text-xs text-fg-muted">
127
+ Message pane — its own toolbar lives here, under the bar
128
+ </div>
129
+ </div>
130
+ </div>
131
+ ),
132
+ };
@@ -0,0 +1,127 @@
1
+ /**
2
+ * ShellTopBar — the app's one search surface and its global actions, composed.
3
+ *
4
+ * `AppTopBar` is geometry; this is what fills it. It spans the whole layout —
5
+ * the nav column, the list, the reading pane and the intelligence rail — and
6
+ * carries the actions that belong to the app rather than to whatever is
7
+ * currently listed or open: the nav toggle, compose, bug report, settings,
8
+ * account. Reply, delete, move and the rest stay on the reading pane's own
9
+ * toolbar, under this bar.
10
+ *
11
+ * It is the app's search, not the list's: the list header drops its own field
12
+ * wherever this bar is mounted, so exactly one search input exists on the page
13
+ * and the "/" shortcut has one target.
14
+ *
15
+ * The field carries one chip: the scope of the view the user navigated into
16
+ * (`in:spam` in Spam, nothing on the brief). Removing it goes to the brief and
17
+ * searches everything. Typed `in:`/`from:` terms are not chipped here — they
18
+ * are already visible as the text the user typed, and chipping them would show
19
+ * the same term twice in one field; they render as chips over the result
20
+ * sections instead, where the text is not repeated.
21
+ *
22
+ * Which actions, in what order, with what wording lives here and only here, so
23
+ * the bar the prototype shows is the bar the app shows.
24
+ */
25
+ import { Bug, Settings, SquarePen } from "lucide-react";
26
+ import type { ReactNode } from "react";
27
+ import { AppTopBar } from "./app-top-bar.js";
28
+ import { Button } from "./button.js";
29
+ import { NavToggleButton } from "./nav-toggle-button.js";
30
+ import { SearchBar } from "./search-bar.js";
31
+ import type { SearchChip } from "./search-chip-input.js";
32
+
33
+ /**
34
+ * How narrowed the search already is. Only the unscoped brief may claim to
35
+ * search all mail; a mailbox route whose name has not loaded yet is already
36
+ * narrowed but has no chip to show, so it gets neutral wording rather than a
37
+ * placeholder that asserts the wrong scope.
38
+ */
39
+ export type ShellSearchScope = "global" | "pending" | "scoped";
40
+
41
+ const SEARCH_PLACEHOLDER: Record<ShellSearchScope, string> = {
42
+ global: "Search all mail",
43
+ pending: "Search mail",
44
+ scoped: "Search this folder",
45
+ };
46
+
47
+ export interface ShellTopBarSearch {
48
+ value: string;
49
+ scope: ShellSearchScope;
50
+ chips?: readonly SearchChip[];
51
+ onChange: (value: string) => void;
52
+ onClear: () => void;
53
+ onClearQuery: () => void;
54
+ onRemoveChip?: (id: string) => void;
55
+ }
56
+
57
+ export interface ShellTopBarProps {
58
+ search: ShellTopBarSearch;
59
+ onCompose: () => void;
60
+ onReportBug: () => void;
61
+ onOpenSettings: () => void;
62
+ /**
63
+ * The key that composes, as the host's keymap reads it — `c`, `⌘N`,
64
+ * `g then b`. The tooltip's wording around it is this component's.
65
+ */
66
+ composeShortcut?: string;
67
+ /**
68
+ * The account control at the bar's trailing edge. An element rather than
69
+ * data: the app hangs a signed-in session and its sign-out off it.
70
+ */
71
+ account: ReactNode;
72
+ }
73
+
74
+ export function ShellTopBar({
75
+ search,
76
+ onCompose,
77
+ onReportBug,
78
+ onOpenSettings,
79
+ composeShortcut,
80
+ account,
81
+ }: ShellTopBarProps) {
82
+ return (
83
+ <AppTopBar
84
+ leading={<NavToggleButton />}
85
+ search={
86
+ <SearchBar
87
+ value={search.value}
88
+ onChange={search.onChange}
89
+ onClear={search.onClear}
90
+ onClearQuery={search.onClearQuery}
91
+ chips={search.chips}
92
+ onRemoveChip={search.onRemoveChip}
93
+ placeholder={SEARCH_PLACEHOLDER[search.scope]}
94
+ />
95
+ }
96
+ actions={
97
+ <>
98
+ <Button
99
+ variant="ghost"
100
+ size="sm"
101
+ icon={<SquarePen className="size-4" />}
102
+ title={composeShortcut ? `Compose (${composeShortcut})` : "Compose"}
103
+ aria-label="Compose"
104
+ onClick={onCompose}
105
+ />
106
+ <Button
107
+ variant="ghost"
108
+ size="sm"
109
+ icon={<Bug className="size-4" />}
110
+ title="Report a bug"
111
+ aria-label="Report a bug"
112
+ onClick={onReportBug}
113
+ />
114
+ <Button
115
+ variant="ghost"
116
+ size="sm"
117
+ icon={<Settings className="size-4" />}
118
+ title="Settings"
119
+ aria-label="Settings"
120
+ onClick={onOpenSettings}
121
+ />
122
+ {account}
123
+ </>
124
+ }
125
+ />
126
+ );
127
+ }
@@ -15,9 +15,7 @@
15
15
  * The second: a finger held on glass drifts, and it drifts past the distance
16
16
  * at which the drag starts tracking. Cancelling the press there meant a real
17
17
  * hold on a phone never entered selection mode — the row followed the drift
18
- * and snapped back, and nothing else happened. The drift cases below run
19
- * against the anchor (`linkComponent`) row the mailbox list actually renders,
20
- * which is also where the native link menu has to stay suppressed.
18
+ * and snapped back, and nothing else happened.
21
19
  */
22
20
 
23
21
  import assert from "node:assert/strict";
@@ -123,36 +121,6 @@ function mount(handlers: Handlers) {
123
121
  return row;
124
122
  }
125
123
 
126
- // The mailbox list renders the open affordance as a real anchor via
127
- // `linkComponent`; the native long-press menu only bites on an `<a href>`.
128
- function mountAnchor(handlers: Handlers) {
129
- act(() => {
130
- root.render(
131
- createElement(SwipeableRow, {
132
- thread,
133
- selectionMode: false,
134
- checked: false,
135
- active: false,
136
- peek: "none",
137
- onPeek: handlers.onPeek,
138
- onToggleCheck: () => undefined,
139
- onLongPress: handlers.onLongPress,
140
- onOpen: handlers.onOpen,
141
- onAct: () => undefined,
142
- linkComponent: ({ onOpenClick, children, ...rowProps }) =>
143
- createElement(
144
- "a",
145
- { ...rowProps, id: "row", href: "/thread/1", onClick: onOpenClick },
146
- children,
147
- ),
148
- }),
149
- );
150
- });
151
- const row = dom.window.document.getElementById("row");
152
- assert.ok(row, "anchor row did not mount");
153
- return row;
154
- }
155
-
156
124
  function contextMenu(row: Element) {
157
125
  const event = new dom.window.MouseEvent("contextmenu", {
158
126
  bubbles: true,
@@ -309,7 +277,7 @@ describe("SwipeableRow gesture wiring (react-aria long press + axis arbitration)
309
277
  let longPressed = 0;
310
278
  let opened = 0;
311
279
  const peeks: SwipePeek[] = [];
312
- const row = mountAnchor({
280
+ const row = mount({
313
281
  onLongPress: () => longPressed++,
314
282
  onOpen: () => opened++,
315
283
  onPeek: (next) => peeks.push(next),
@@ -331,7 +299,7 @@ describe("SwipeableRow gesture wiring (react-aria long press + axis arbitration)
331
299
  it("a hold that drifts vertically past the axis threshold still enters selection mode", async () => {
332
300
  let longPressed = 0;
333
301
  const peeks: SwipePeek[] = [];
334
- const row = mountAnchor({
302
+ const row = mount({
335
303
  onLongPress: () => longPressed++,
336
304
  onOpen: () => undefined,
337
305
  onPeek: (next) => peeks.push(next),
@@ -349,10 +317,10 @@ describe("SwipeableRow gesture wiring (react-aria long press + axis arbitration)
349
317
 
350
318
  it("an aborted short drag opens nothing on release", async () => {
351
319
  // The drag claimed the axis but never reached the escape distance, so the
352
- // press was still live. react-aria synthesizes a click for an unresolved
353
- // press, which on the anchor row is a navigation.
320
+ // press was still live, and react-aria synthesizes a click for an
321
+ // unresolved press.
354
322
  let clicks = 0;
355
- const row = mountAnchor({
323
+ const row = mount({
356
324
  onLongPress: () => undefined,
357
325
  onOpen: () => undefined,
358
326
  onPeek: () => undefined,
@@ -367,10 +335,10 @@ describe("SwipeableRow gesture wiring (react-aria long press + axis arbitration)
367
335
  assert.equal(clicks, 0);
368
336
  });
369
337
 
370
- it("suppresses the native link menu raised over a drifting hold", async () => {
371
- // Android Chrome raises the anchor's context menu at its own threshold,
372
- // which can land mid-drift and ahead of the app's long press.
373
- const row = mountAnchor({
338
+ it("suppresses the native context menu raised over a drifting hold", async () => {
339
+ // Android Chrome raises its own menu at its own threshold, which can land
340
+ // mid-drift and ahead of the app's long press.
341
+ const row = mount({
374
342
  onLongPress: () => undefined,
375
343
  onOpen: () => undefined,
376
344
  onPeek: () => undefined,
@@ -3,11 +3,7 @@ import { describe, it } from "node:test";
3
3
  import { createElement } from "react";
4
4
  import { renderToString } from "react-dom/server";
5
5
  import type { ThreadRowData } from "./app-shell-types.js";
6
- import {
7
- SwipeableRow,
8
- type SwipeableRowOpenProps,
9
- type SwipePeek,
10
- } from "./swipeable-row.js";
6
+ import { SwipeableRow, type SwipePeek } from "./swipeable-row.js";
11
7
 
12
8
  const thread: ThreadRowData = {
13
9
  id: "thread-1",
@@ -63,45 +59,14 @@ describe("SwipeableRow", () => {
63
59
  assert.match(read, /aria-label="Mark as unread"/);
64
60
  });
65
61
 
66
- it("renders the open affordance as a button by default", () => {
67
- const html = render("none");
68
- assert.match(html, /<button[^>]*>/);
69
- assert.doesNotMatch(html, /<a /);
70
- });
71
-
72
- it("renders the open affordance as a real anchor when linkComponent is given", () => {
73
- const html = renderToString(
74
- createElement(SwipeableRow, {
75
- ...baseProps,
76
- peek: "none",
77
- linkComponent: ({ className, children }: SwipeableRowOpenProps) =>
78
- createElement(
79
- "a",
80
- { href: "/mail/m1?selectedMessageId=t1", className },
81
- children,
82
- ),
83
- }),
84
- );
85
- assert.match(html, /<a [^>]*href="\/mail\/m1\?selectedMessageId=t1"/);
86
- assert.match(html, /Q3 planning notes/);
87
- });
88
-
89
- it("keeps the button (not the anchor) in selection mode even with a linkComponent", () => {
90
- const html = renderToString(
91
- createElement(SwipeableRow, {
92
- ...baseProps,
93
- selectionMode: true,
94
- peek: "none",
95
- linkComponent: ({ className, children }: SwipeableRowOpenProps) =>
96
- createElement(
97
- "a",
98
- { href: "/should-not-render", className },
99
- children,
100
- ),
101
- }),
102
- );
103
- assert.doesNotMatch(html, /<a /);
104
- assert.match(html, /<button[^>]*>/);
62
+ it("opens through a button, never an anchor (#116)", () => {
63
+ for (const html of [
64
+ render("none"),
65
+ render("none", { selectionMode: true }),
66
+ ]) {
67
+ assert.match(html, /<button[^>]*data-message-row/);
68
+ assert.doesNotMatch(html, /<a /);
69
+ }
105
70
  });
106
71
 
107
72
  it("gives the row checkbox semantics while in selection mode", () => {
@@ -69,31 +69,6 @@ export const SelectionChecked: Story = {
69
69
  args: { peek: "none", selectionMode: true, checked: true },
70
70
  };
71
71
 
72
- /**
73
- * The open affordance is rendered as a real `<a href>` via `linkComponent`,
74
- * so deep-link, middle-click and open-in-new-tab work. Consumers pass their
75
- * router's Link; here a plain anchor stands in. Inspect the DOM: the row is an
76
- * anchor, not a button.
77
- */
78
- export const AsAnchor: Story = {
79
- name: "As anchor (linkComponent)",
80
- args: {
81
- peek: "none",
82
- linkComponent: ({ onOpenClick, children, ...rowProps }) => (
83
- <a
84
- {...rowProps}
85
- href="/mail/inbox?selectedMessageId=thread-1"
86
- onClick={(e) => {
87
- e.preventDefault();
88
- onOpenClick(e);
89
- }}
90
- >
91
- {children}
92
- </a>
93
- ),
94
- },
95
- };
96
-
97
72
  export const Acting: Story = {
98
73
  name: "Acting (interactive)",
99
74
  render: () => {
@@ -130,7 +105,7 @@ export const Acting: Story = {
130
105
  },
131
106
  };
132
107
 
133
- function AnchorRow({
108
+ function GestureRow({
134
109
  onLongPress,
135
110
  selectionMode,
136
111
  checked,
@@ -147,23 +122,14 @@ function AnchorRow({
147
122
  selectionMode={selectionMode ?? false}
148
123
  checked={checked ?? false}
149
124
  onLongPress={onLongPress ?? (() => undefined)}
150
- linkComponent={({ onOpenClick, children, ...rowProps }) => (
151
- <a
152
- {...rowProps}
153
- href="/mail/inbox?selectedMessageId=thread-1"
154
- onClick={(e) => {
155
- e.preventDefault();
156
- onOpenClick(e);
157
- }}
158
- >
159
- {children}
160
- </a>
161
- )}
162
125
  />
163
126
  </PhoneFrame>
164
127
  );
165
128
  }
166
129
 
130
+ const rowElement = (canvasElement: HTMLElement) =>
131
+ canvasElement.querySelector<HTMLButtonElement>("button[data-message-row]");
132
+
167
133
  /**
168
134
  * The long press is the way into multi-select on touch. Press and hold the row
169
135
  * (mouse hold works too — react-aria fires the long press for both) and it
@@ -177,7 +143,7 @@ export const LongPressToSelect: Story = {
177
143
  const [selected, setSelected] = useState(false);
178
144
  return (
179
145
  <div className="space-y-2">
180
- <AnchorRow
146
+ <GestureRow
181
147
  selectionMode={selected}
182
148
  checked={selected}
183
149
  onLongPress={() => setSelected((v) => !v)}
@@ -206,7 +172,7 @@ export const LongPressWithDrift: Story = {
206
172
  const [selected, setSelected] = useState(false);
207
173
  return (
208
174
  <div className="space-y-2">
209
- <AnchorRow
175
+ <GestureRow
210
176
  selectionMode={selected}
211
177
  checked={selected}
212
178
  onLongPress={() => setSelected(true)}
@@ -220,10 +186,10 @@ export const LongPressWithDrift: Story = {
220
186
  );
221
187
  },
222
188
  play: async ({ canvasElement }) => {
223
- const anchor = canvasElement.querySelector<HTMLAnchorElement>("a[href]");
224
- if (!anchor) return;
189
+ const row = rowElement(canvasElement);
190
+ if (!row) return;
225
191
  const touch = (type: string, clientX: number, clientY: number) =>
226
- anchor.dispatchEvent(
192
+ row.dispatchEvent(
227
193
  new PointerEvent(type, {
228
194
  bubbles: true,
229
195
  pointerType: "touch",
@@ -247,30 +213,29 @@ export const LongPressWithDrift: Story = {
247
213
  };
248
214
 
249
215
  /**
250
- * A touch long press over a link row normally raises the browser's own link
251
- * context menu ("Open in new tab / Copy link address"), which collides with the
252
- * long-press-to-select gesture above. `useLongPress` suppresses that menu when
253
- * the press came from touch or pen, while leaving a mouse right-click's menu
254
- * alone. The play step drives a synthetic touch press then a contextmenu and
255
- * writes the outcome below.
216
+ * A touch long press over a row normally raises the browser's own context menu,
217
+ * which collides with the long-press-to-select gesture above. `useLongPress`
218
+ * suppresses that menu when the press came from touch or pen, while leaving a
219
+ * mouse right-click's menu alone. The play step drives a synthetic touch press
220
+ * then a contextmenu and writes the outcome below.
256
221
  */
257
222
  export const TouchContextMenuSuppressed: Story = {
258
223
  name: "Touch context menu suppressed",
259
224
  render: () => (
260
225
  <div className="space-y-2">
261
- <AnchorRow />
226
+ <GestureRow />
262
227
  <p data-testid="context-menu-outcome" className="text-xs text-fg-muted">
263
228
  Waiting for a touch press…
264
229
  </p>
265
230
  </div>
266
231
  ),
267
232
  play: async ({ canvasElement }) => {
268
- const anchor = canvasElement.querySelector<HTMLAnchorElement>("a[href]");
233
+ const row = rowElement(canvasElement);
269
234
  const outcome = canvasElement.querySelector<HTMLParagraphElement>(
270
235
  '[data-testid="context-menu-outcome"]',
271
236
  );
272
- if (!anchor || !outcome) return;
273
- anchor.dispatchEvent(
237
+ if (!row || !outcome) return;
238
+ row.dispatchEvent(
274
239
  new PointerEvent("pointerdown", {
275
240
  bubbles: true,
276
241
  pointerType: "touch",
@@ -281,7 +246,7 @@ export const TouchContextMenuSuppressed: Story = {
281
246
  bubbles: true,
282
247
  cancelable: true,
283
248
  });
284
- anchor.dispatchEvent(menu);
249
+ row.dispatchEvent(menu);
285
250
  outcome.textContent = menu.defaultPrevented
286
251
  ? "Native context menu suppressed on touch."
287
252
  : "Native context menu allowed.";
@@ -1,4 +1,3 @@
1
- import type { DOMAttributes } from "@react-types/shared";
2
1
  import { Check, Mail, MailOpen, Trash2 } from "lucide-react";
3
2
  import { useRef, useState } from "react";
4
3
  import { mergeProps } from "react-aria";
@@ -64,26 +63,6 @@ export function commitPeek(offset: number): SwipePeek {
64
63
  return "none";
65
64
  }
66
65
 
67
- /**
68
- * Props the row's interactive (open) element must receive — the swipe gesture
69
- * handlers (merged with react-aria's long-press props: pointer handlers,
70
- * `aria-describedby`, and friends), the transform/transition style, the row
71
- * body, plus an onClick that suppresses navigation when the row is peeked. A
72
- * consumer passes `linkComponent` to render these on a real anchor (e.g. a
73
- * router Link) so the open affordance is a true `<a href>` — keeping
74
- * open-in-new-tab, middle-click, deep-link and a11y — instead of the default
75
- * JS-only `<button onOpen>`.
76
- */
77
- export interface SwipeableRowOpenProps extends DOMAttributes {
78
- className: string;
79
- style: React.CSSProperties;
80
- /** Wire to the anchor's onClick. When the row is peeked it calls
81
- * preventDefault so a tap closes the peek instead of navigating; otherwise
82
- * it is a no-op and the anchor's native navigation proceeds. */
83
- onOpenClick: (e: { preventDefault: () => void }) => void;
84
- children: React.ReactNode;
85
- }
86
-
87
66
  export function SwipeableRow({
88
67
  thread,
89
68
  selectionMode,
@@ -95,7 +74,6 @@ export function SwipeableRow({
95
74
  onLongPress,
96
75
  onOpen,
97
76
  onAct,
98
- linkComponent,
99
77
  }: {
100
78
  thread: ThreadRowData;
101
79
  selectionMode: boolean;
@@ -109,12 +87,6 @@ export function SwipeableRow({
109
87
  /** Tapping a revealed action performs it: "leading" = toggle read,
110
88
  * "trailing" = delete. */
111
89
  onAct: (side: "leading" | "trailing") => void;
112
- /** Render the open affordance as a real anchor (router Link / `<a href>`)
113
- * instead of the default `<button onOpen>`. Receives the gesture handlers,
114
- * style, peek-aware onClickCapture and row body to spread onto the anchor.
115
- * When provided, a plain tap navigates via the anchor's native click (so
116
- * deep-link/middle-click work); onOpen is not called for the tap. */
117
- linkComponent?: (props: SwipeableRowOpenProps) => React.ReactNode;
118
90
  }) {
119
91
  const gesture = useRef<{
120
92
  startX: number;
@@ -197,9 +169,8 @@ export function SwipeableRow({
197
169
  // or a real cancel arrived) — those handle themselves, so do nothing.
198
170
  if (!g) return;
199
171
  // A drag that claimed an axis but never escaped left react-aria's press
200
- // live, and an unresolved press synthesizes a click on release which on
201
- // the anchor row means navigating. The drag was neither a swipe nor a
202
- // tap, so end the press with it.
172
+ // live, and an unresolved press synthesizes a click on release. The drag
173
+ // was neither a swipe nor a tap, so end the press with it.
203
174
  if (g.axis !== "none" && !g.escaped) cancelLongPress(e);
204
175
  if (g.axis === "horizontal") {
205
176
  if (offset !== null) onPeek(commitPeek(offset));
@@ -216,8 +187,6 @@ export function SwipeableRow({
216
187
  onPeek("none");
217
188
  return;
218
189
  }
219
- // A real anchor handles the open via its native click — don't double-fire.
220
- if (linkComponent) return;
221
190
  onOpen();
222
191
  };
223
192
 
@@ -242,12 +211,6 @@ export function SwipeableRow({
242
211
  onPointerCancel,
243
212
  });
244
213
 
245
- // A tap on a peeked anchor must close the peek, not navigate; suppress the
246
- // native click in that case. onPointerUp already snapped it closed.
247
- const onOpenClick = (e: { preventDefault: () => void }) => {
248
- if (peek !== "none") e.preventDefault();
249
- };
250
-
251
214
  const offset = dragX ?? peekOffset(peek);
252
215
  const revealed: SwipePeek =
253
216
  offset > 0 ? "leading" : offset < 0 ? "trailing" : "none";
@@ -255,12 +218,9 @@ export function SwipeableRow({
255
218
  const interactiveClassName = cn(
256
219
  // opaque bg so the row occludes the action behind it until peeked
257
220
  "relative touch-pan-y bg-surface",
258
- // This row's long press enters selection mode; without these, Android
259
- // Chrome opens the link context menu / starts text selection and iOS
260
- // Safari fires the callout, racing the app's handler. useLongPress
261
- // suppresses the touch-fired contextmenu; the callout fires no
262
- // cancelable event, so CSS is the only lever left for it.
263
- "select-none [-webkit-touch-callout:none]",
221
+ // This row's long press enters selection mode; without this, the hold
222
+ // starts a native text selection across the row's subject and snippet.
223
+ "select-none",
264
224
  comfortableRowClass({ active: checked || active }),
265
225
  );
266
226
  const interactiveStyle: React.CSSProperties = {
@@ -297,14 +257,14 @@ export function SwipeableRow({
297
257
  )}
298
258
  {/*
299
259
  * Tappable, focusable entry point into selection mode — long-press is
300
- * never the only way in. Nested inside the row's own open control
301
- * (button or, via linkComponent, an anchor); mirrors the leading-slot
302
- * toggle already shipped in the web client's row.
260
+ * never the only way in. A span, not a <button>: it sits inside the
261
+ * row's own open button, which may not nest one. The checkbox role and
262
+ * label are what the user and the test suite address, so both stay.
303
263
  */}
304
- {/* 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 */}
305
- <button
306
- type="button"
264
+ {/* biome-ignore lint/a11y/useSemanticElements: a native <input type="checkbox"> can't host the Avatar as its visible content, and a nested <button> inside the row's own button is invalid HTML */}
265
+ <span
307
266
  role="checkbox"
267
+ tabIndex={0}
308
268
  aria-checked={checked}
309
269
  aria-label={`Select message from ${thread.fromName}`}
310
270
  onPointerDown={stopRowGesture}
@@ -315,10 +275,16 @@ export function SwipeableRow({
315
275
  e.stopPropagation();
316
276
  onLongPress();
317
277
  }}
278
+ onKeyDown={(e) => {
279
+ if (e.key !== "Enter" && e.key !== " ") return;
280
+ e.preventDefault();
281
+ e.stopPropagation();
282
+ onLongPress();
283
+ }}
318
284
  className="-m-2 inline-flex size-11 shrink-0 items-center justify-center rounded-full"
319
285
  >
320
286
  <Avatar name={thread.fromName} email={thread.fromEmail} size="sm" />
321
- </button>
287
+ </span>
322
288
  <ComfortableRowTextContent thread={thread} />
323
289
  </>
324
290
  );
@@ -350,29 +316,21 @@ export function SwipeableRow({
350
316
  </button>
351
317
  )}
352
318
 
353
- {/* In selection mode the row is tap-to-toggle, never a navigation link —
354
- keep the button so a checkbox tap can't open a thread. */}
355
- {linkComponent && !selectionMode ? (
356
- linkComponent({
357
- ...gestureProps,
358
- className: interactiveClassName,
359
- style: interactiveStyle,
360
- onOpenClick,
361
- children: body,
362
- })
363
- ) : (
364
- // 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
365
- <button
366
- type="button"
367
- role={selectionMode ? "checkbox" : undefined}
368
- aria-checked={selectionMode ? checked : undefined}
369
- {...gestureProps}
370
- className={interactiveClassName}
371
- style={interactiveStyle}
372
- >
373
- {body}
374
- </button>
375
- )}
319
+ {/* `data-message-row` is the mail app's row marker: its key dispatcher
320
+ reads it to tell a row apart from a control nested inside one, and
321
+ the e2e suite locates rows by it. */}
322
+ {/* 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 */}
323
+ <button
324
+ type="button"
325
+ role={selectionMode ? "checkbox" : undefined}
326
+ aria-checked={selectionMode ? checked : undefined}
327
+ data-message-row
328
+ {...gestureProps}
329
+ className={interactiveClassName}
330
+ style={interactiveStyle}
331
+ >
332
+ {body}
333
+ </button>
376
334
  </div>
377
335
  );
378
336
  }
package/src/index.ts CHANGED
@@ -582,6 +582,12 @@ export {
582
582
  SettingsShell,
583
583
  type SettingsShellProps,
584
584
  } from "./components/settings-screen.js";
585
+ export {
586
+ type ShellSearchScope,
587
+ ShellTopBar,
588
+ type ShellTopBarProps,
589
+ type ShellTopBarSearch,
590
+ } from "./components/shell-top-bar.js";
585
591
  export {
586
592
  SlidePanel,
587
593
  type SlidePanelProps,
@@ -598,7 +604,6 @@ export {
598
604
  export {
599
605
  commitPeek,
600
606
  SwipeableRow,
601
- type SwipeableRowOpenProps,
602
607
  type SwipePeek,
603
608
  } from "./components/swipeable-row.js";
604
609
  export { TouchListBody } from "./components/touch-list.js";