@remit/ui 0.0.78 → 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,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
|
+
}
|
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,
|