@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 +1 -1
- package/src/components/app-top-bar.stories.tsx +48 -151
- package/src/components/shell-top-bar.render.test.ts +95 -0
- package/src/components/shell-top-bar.stories.tsx +132 -0
- package/src/components/shell-top-bar.tsx +127 -0
- package/src/components/swipeable-row.gesture.test.ts +10 -42
- package/src/components/swipeable-row.render.test.ts +9 -44
- package/src/components/swipeable-row.stories.tsx +19 -54
- package/src/components/swipeable-row.tsx +33 -75
- package/src/index.ts +6 -1
package/package.json
CHANGED
|
@@ -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 {
|
|
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
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
|
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
|
-
<
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
/**
|
|
85
|
-
const
|
|
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
|
-
<
|
|
143
|
-
|
|
144
|
-
|
|
44
|
+
<AppTopBar
|
|
45
|
+
leading={<Slot label="leading" />}
|
|
46
|
+
search={<Field />}
|
|
47
|
+
actions={<Slot label="actions" />}
|
|
145
48
|
/>
|
|
146
49
|
),
|
|
147
50
|
};
|
|
148
51
|
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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
|
-
/**
|
|
159
|
-
*
|
|
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
|
-
<
|
|
163
|
-
<
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
<
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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.
|
|
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 =
|
|
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 =
|
|
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
|
|
353
|
-
// press
|
|
320
|
+
// press was still live, and react-aria synthesizes a click for an
|
|
321
|
+
// unresolved press.
|
|
354
322
|
let clicks = 0;
|
|
355
|
-
const row =
|
|
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
|
|
371
|
-
// Android Chrome raises
|
|
372
|
-
//
|
|
373
|
-
const row =
|
|
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("
|
|
67
|
-
const html
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
|
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
|
-
<
|
|
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
|
-
<
|
|
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
|
|
224
|
-
if (!
|
|
189
|
+
const row = rowElement(canvasElement);
|
|
190
|
+
if (!row) return;
|
|
225
191
|
const touch = (type: string, clientX: number, clientY: number) =>
|
|
226
|
-
|
|
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
|
|
251
|
-
*
|
|
252
|
-
*
|
|
253
|
-
*
|
|
254
|
-
*
|
|
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
|
-
<
|
|
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
|
|
233
|
+
const row = rowElement(canvasElement);
|
|
269
234
|
const outcome = canvasElement.querySelector<HTMLParagraphElement>(
|
|
270
235
|
'[data-testid="context-menu-outcome"]',
|
|
271
236
|
);
|
|
272
|
-
if (!
|
|
273
|
-
|
|
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
|
-
|
|
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
|
|
201
|
-
//
|
|
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
|
|
259
|
-
//
|
|
260
|
-
|
|
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.
|
|
301
|
-
*
|
|
302
|
-
*
|
|
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
|
|
305
|
-
<
|
|
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
|
-
</
|
|
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
|
-
{/*
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
}
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
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";
|