@remit/ui 0.0.2 → 0.0.4
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.render.test.ts +54 -0
- package/src/components/app-top-bar.stories.tsx +136 -0
- package/src/components/app-top-bar.tsx +62 -0
- package/src/components/mail-action-toolbar.render.test.ts +2 -2
- package/src/components/mail-action-toolbar.tsx +2 -2
- package/src/components/mobile-message-action-bar.render.test.ts +2 -2
- package/src/components/mobile-message-action-bar.tsx +2 -2
- package/src/components/mobile-reading-pane.render.test.ts +1 -1
- package/src/components/mobile-search-view.stories.tsx +23 -0
- package/src/components/mobile-search-view.tsx +14 -0
- package/src/components/nav-sidebar.render.test.ts +12 -0
- package/src/components/nav-sidebar.tsx +2 -2
- package/src/components/search-bar.render.test.ts +17 -0
- package/src/components/search-bar.stories.tsx +3 -1
- package/src/components/search-bar.tsx +49 -81
- package/src/components/search-chip-input.render.test.ts +212 -0
- package/src/components/search-chip-input.stories.tsx +171 -0
- package/src/components/search-chip-input.tsx +364 -0
- package/src/components/search-chip-keys.test.ts +206 -0
- package/src/components/search-chip-keys.ts +154 -0
- package/src/components/search-token-chip.tsx +111 -4
- package/src/filter-presets.test.ts +8 -1
- package/src/filter-presets.ts +2 -1
- package/src/index.ts +9 -0
package/package.json
CHANGED
|
@@ -0,0 +1,54 @@
|
|
|
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 { AppTopBar, type AppTopBarProps } from "./app-top-bar.js";
|
|
6
|
+
|
|
7
|
+
const slot = (testId: string) =>
|
|
8
|
+
createElement("div", { "data-testid": testId }, testId);
|
|
9
|
+
|
|
10
|
+
const render = (overrides: Partial<AppTopBarProps> = {}): string =>
|
|
11
|
+
renderToString(
|
|
12
|
+
createElement(AppTopBar, {
|
|
13
|
+
search: slot("search"),
|
|
14
|
+
...overrides,
|
|
15
|
+
}),
|
|
16
|
+
);
|
|
17
|
+
|
|
18
|
+
describe("AppTopBar", () => {
|
|
19
|
+
it("renders the search slot — the bar's reason to exist", () => {
|
|
20
|
+
assert.match(render(), /data-testid="search"/);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("renders the leading and action slots when supplied", () => {
|
|
24
|
+
const html = render({
|
|
25
|
+
leading: slot("leading"),
|
|
26
|
+
actions: slot("actions"),
|
|
27
|
+
});
|
|
28
|
+
assert.match(html, /data-testid="leading"/);
|
|
29
|
+
assert.match(html, /data-testid="actions"/);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("omits the optional slots entirely rather than leaving empty boxes", () => {
|
|
33
|
+
const html = render();
|
|
34
|
+
assert.doesNotMatch(html, /data-testid="leading"/);
|
|
35
|
+
assert.doesNotMatch(html, /data-testid="actions"/);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("lays the bar out leading · search · actions", () => {
|
|
39
|
+
const html = render({
|
|
40
|
+
leading: slot("leading"),
|
|
41
|
+
actions: slot("actions"),
|
|
42
|
+
});
|
|
43
|
+
assert.ok(
|
|
44
|
+
html.indexOf("leading") < html.indexOf("search") &&
|
|
45
|
+
html.indexOf("search") < html.indexOf("actions"),
|
|
46
|
+
"slots render in reading order",
|
|
47
|
+
);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("is a banner landmark spanning the app, not a pane header", () => {
|
|
51
|
+
assert.match(render(), /<header/);
|
|
52
|
+
assert.match(render(), /w-full/);
|
|
53
|
+
});
|
|
54
|
+
});
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
|
+
import { Bug, Menu, SquarePen } from "lucide-react";
|
|
3
|
+
import { useState } from "react";
|
|
4
|
+
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";
|
|
8
|
+
|
|
9
|
+
const meta: Meta<typeof AppTopBar> = {
|
|
10
|
+
title: "Mail/AppTopBar",
|
|
11
|
+
component: AppTopBar,
|
|
12
|
+
parameters: { layout: "fullscreen" },
|
|
13
|
+
};
|
|
14
|
+
export default meta;
|
|
15
|
+
|
|
16
|
+
type Story = StoryObj<typeof AppTopBar>;
|
|
17
|
+
|
|
18
|
+
const Brand = () => (
|
|
19
|
+
<>
|
|
20
|
+
<Button
|
|
21
|
+
variant="ghost"
|
|
22
|
+
icon={<Menu className="size-5" />}
|
|
23
|
+
aria-label="Menu"
|
|
24
|
+
className="px-0 lg:hidden"
|
|
25
|
+
/>
|
|
26
|
+
<span className="px-1 text-sm font-semibold tracking-tight text-fg">
|
|
27
|
+
remit
|
|
28
|
+
</span>
|
|
29
|
+
</>
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
const Actions = () => (
|
|
33
|
+
<>
|
|
34
|
+
<Button
|
|
35
|
+
variant="ghost"
|
|
36
|
+
size="sm"
|
|
37
|
+
icon={<SquarePen className="size-4" />}
|
|
38
|
+
title="Compose"
|
|
39
|
+
aria-label="Compose"
|
|
40
|
+
/>
|
|
41
|
+
<Button
|
|
42
|
+
variant="ghost"
|
|
43
|
+
size="sm"
|
|
44
|
+
icon={<Bug className="size-4" />}
|
|
45
|
+
title="Report a bug"
|
|
46
|
+
aria-label="Report a bug"
|
|
47
|
+
/>
|
|
48
|
+
<Avatar name="Matthijs van Henten" email="mvh@example.com" size="sm" />
|
|
49
|
+
</>
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
const SCOPE: SearchChip = { id: "in:spam", label: "in:spam", tone: "scope" };
|
|
53
|
+
|
|
54
|
+
const Bar = ({ initialChips = [] }: { initialChips?: SearchChip[] }) => {
|
|
55
|
+
const [chips, setChips] = useState<SearchChip[]>(initialChips);
|
|
56
|
+
const [value, setValue] = useState("");
|
|
57
|
+
return (
|
|
58
|
+
<AppTopBar
|
|
59
|
+
leading={<Brand />}
|
|
60
|
+
actions={<Actions />}
|
|
61
|
+
search={
|
|
62
|
+
<SearchChipInput
|
|
63
|
+
size="lg"
|
|
64
|
+
chips={chips}
|
|
65
|
+
onRemoveChip={(id) => setChips((cs) => cs.filter((c) => c.id !== id))}
|
|
66
|
+
value={value}
|
|
67
|
+
onChange={setValue}
|
|
68
|
+
onClear={() => {
|
|
69
|
+
setValue("");
|
|
70
|
+
setChips([]);
|
|
71
|
+
}}
|
|
72
|
+
onClearQuery={() => setValue("")}
|
|
73
|
+
globalFocusKey={false}
|
|
74
|
+
placeholder="Search all mail"
|
|
75
|
+
/>
|
|
76
|
+
}
|
|
77
|
+
/>
|
|
78
|
+
);
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
/** Over the panes it spans, so the arrangement reads the way it will in the app. */
|
|
82
|
+
const WithPanes = ({ children }: { children: React.ReactNode }) => (
|
|
83
|
+
<div className="flex h-96 flex-col bg-canvas">
|
|
84
|
+
{children}
|
|
85
|
+
<div className="flex min-h-0 flex-1">
|
|
86
|
+
<div className="w-56 shrink-0 border-r border-line bg-surface p-3 text-xs text-fg-muted">
|
|
87
|
+
Nav
|
|
88
|
+
</div>
|
|
89
|
+
<div className="w-72 shrink-0 border-r border-line bg-surface p-3 text-xs text-fg-muted">
|
|
90
|
+
Message list
|
|
91
|
+
</div>
|
|
92
|
+
<div className="min-w-0 flex-1 p-3 text-xs text-fg-muted">
|
|
93
|
+
Message pane — its own toolbar lives here, under the bar
|
|
94
|
+
</div>
|
|
95
|
+
</div>
|
|
96
|
+
</div>
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
/** The daily brief's state: search unscoped, nothing narrowing it. */
|
|
100
|
+
export const Unscoped: Story = {
|
|
101
|
+
render: () => <Bar />,
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* A narrowing scope in the bar, tinted to mark it as the view the user is in
|
|
106
|
+
* rather than a filter they typed. Removing it widens the search again.
|
|
107
|
+
*/
|
|
108
|
+
export const Scoped: Story = {
|
|
109
|
+
render: () => <Bar initialChips={[SCOPE]} />,
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
/** The arrangement: one bar over the nav, the list, and the message pane. */
|
|
113
|
+
export const OverTheLayout: Story = {
|
|
114
|
+
render: () => (
|
|
115
|
+
<WithPanes>
|
|
116
|
+
<Bar initialChips={[SCOPE]} />
|
|
117
|
+
</WithPanes>
|
|
118
|
+
),
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
export const SearchOnly: Story = {
|
|
122
|
+
render: () => (
|
|
123
|
+
<AppTopBar
|
|
124
|
+
search={
|
|
125
|
+
<SearchChipInput
|
|
126
|
+
size="lg"
|
|
127
|
+
value=""
|
|
128
|
+
onChange={() => undefined}
|
|
129
|
+
onClear={() => undefined}
|
|
130
|
+
globalFocusKey={false}
|
|
131
|
+
placeholder="Search all mail"
|
|
132
|
+
/>
|
|
133
|
+
}
|
|
134
|
+
/>
|
|
135
|
+
),
|
|
136
|
+
};
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import { cn } from "../lib/cn.js";
|
|
3
|
+
|
|
4
|
+
export interface AppTopBarProps {
|
|
5
|
+
/**
|
|
6
|
+
* Leading slot — the brand mark, and on narrow widths the nav trigger.
|
|
7
|
+
* Sits over the nav column, so keep it to the nav pane's width budget.
|
|
8
|
+
*/
|
|
9
|
+
leading?: ReactNode;
|
|
10
|
+
/**
|
|
11
|
+
* The search field. Spans the bar's middle and is the only thing that
|
|
12
|
+
* grows, so the bar reads as one search surface for the whole app.
|
|
13
|
+
*/
|
|
14
|
+
search: ReactNode;
|
|
15
|
+
/**
|
|
16
|
+
* Global actions — compose, feedback, avatar. Actions that belong to the
|
|
17
|
+
* app rather than to whatever is currently listed or open; message-context
|
|
18
|
+
* verbs live in the message pane's own toolbar, under this bar.
|
|
19
|
+
*/
|
|
20
|
+
actions?: ReactNode;
|
|
21
|
+
className?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The application top bar: one full-width row above every pane, carrying
|
|
26
|
+
* search and the global actions.
|
|
27
|
+
*
|
|
28
|
+
* Search sits here rather than over the message list because it is not the
|
|
29
|
+
* list's search — it reads across the whole app, and the bar's width is what
|
|
30
|
+
* says so. Leading brand and nav trigger, then the search field taking the
|
|
31
|
+
* room it needs, then the global actions.
|
|
32
|
+
*
|
|
33
|
+
* Presentational and slot-driven; the host supplies the wired field and
|
|
34
|
+
* action controls.
|
|
35
|
+
*/
|
|
36
|
+
export function AppTopBar({
|
|
37
|
+
leading,
|
|
38
|
+
search,
|
|
39
|
+
actions,
|
|
40
|
+
className,
|
|
41
|
+
}: AppTopBarProps) {
|
|
42
|
+
return (
|
|
43
|
+
<header
|
|
44
|
+
className={cn(
|
|
45
|
+
// min-h, not a fixed height: the search field grows when its chips
|
|
46
|
+
// wrap onto a second line.
|
|
47
|
+
"flex min-h-16 w-full shrink-0 items-center gap-3 border-b border-line bg-canvas px-3 py-2",
|
|
48
|
+
className,
|
|
49
|
+
)}
|
|
50
|
+
>
|
|
51
|
+
{leading && (
|
|
52
|
+
<div className="flex shrink-0 items-center gap-2">{leading}</div>
|
|
53
|
+
)}
|
|
54
|
+
<div className="flex min-w-0 flex-1 justify-start">
|
|
55
|
+
<div className="w-full max-w-2xl">{search}</div>
|
|
56
|
+
</div>
|
|
57
|
+
{actions && (
|
|
58
|
+
<div className="flex shrink-0 items-center gap-1">{actions}</div>
|
|
59
|
+
)}
|
|
60
|
+
</header>
|
|
61
|
+
);
|
|
62
|
+
}
|
|
@@ -11,7 +11,7 @@ describe("MailActionToolbar", () => {
|
|
|
11
11
|
);
|
|
12
12
|
assert.match(html, /aria-label="Reply"/);
|
|
13
13
|
assert.match(html, /aria-label="Move to Trash"/);
|
|
14
|
-
assert.match(html, /aria-label="
|
|
14
|
+
assert.match(html, /aria-label="Star"/);
|
|
15
15
|
assert.match(html, /aria-label="Move to mailbox"/);
|
|
16
16
|
// No archive verb — Remit is IMAP-backed (move-to-folder is the equivalent).
|
|
17
17
|
assert.doesNotMatch(html, /aria-label="Archive"/);
|
|
@@ -27,7 +27,7 @@ describe("MailActionToolbar", () => {
|
|
|
27
27
|
assert.match(html, /aria-label="Forward"/);
|
|
28
28
|
assert.doesNotMatch(html, /aria-label="Archive"/);
|
|
29
29
|
assert.doesNotMatch(html, /aria-label="Move to Trash"/);
|
|
30
|
-
assert.doesNotMatch(html, /aria-label="
|
|
30
|
+
assert.doesNotMatch(html, /aria-label="Star"/);
|
|
31
31
|
assert.doesNotMatch(html, /aria-label="Move to mailbox"/);
|
|
32
32
|
});
|
|
33
33
|
|
|
@@ -81,7 +81,7 @@ export function MailActionToolbar({
|
|
|
81
81
|
replyAllTitle = "Reply all (a)",
|
|
82
82
|
forwardTitle = "Forward (f)",
|
|
83
83
|
deleteTitle = "Move to Trash (#)",
|
|
84
|
-
flagTitle = "
|
|
84
|
+
flagTitle = "Star (s)",
|
|
85
85
|
children,
|
|
86
86
|
className,
|
|
87
87
|
}: MailActionToolbarProps) {
|
|
@@ -158,7 +158,7 @@ export function MailActionToolbar({
|
|
|
158
158
|
/>
|
|
159
159
|
}
|
|
160
160
|
title={flagTitle}
|
|
161
|
-
aria-label="
|
|
161
|
+
aria-label="Star"
|
|
162
162
|
onClick={act("flag", onToggleStar)}
|
|
163
163
|
/>
|
|
164
164
|
</>
|
|
@@ -20,7 +20,7 @@ describe("MobileMessageActionBar", () => {
|
|
|
20
20
|
}),
|
|
21
21
|
}),
|
|
22
22
|
);
|
|
23
|
-
assert.equal(countMatches(html, 'aria-label="
|
|
23
|
+
assert.equal(countMatches(html, 'aria-label="Star"'), 1);
|
|
24
24
|
assert.equal(countMatches(html, 'aria-label="Move to folder"'), 1);
|
|
25
25
|
assert.equal(countMatches(html, 'aria-label="Move to Trash"'), 1);
|
|
26
26
|
assert.doesNotMatch(html, /aria-label="Archive"/);
|
|
@@ -30,7 +30,7 @@ describe("MobileMessageActionBar", () => {
|
|
|
30
30
|
const html = renderToString(
|
|
31
31
|
createElement(MobileMessageActionBar, { ...base, isStarred: true }),
|
|
32
32
|
);
|
|
33
|
-
assert.match(html, /aria-label="
|
|
33
|
+
assert.match(html, /aria-label="Unstar"/);
|
|
34
34
|
assert.match(html, /aria-pressed="true"/);
|
|
35
35
|
});
|
|
36
36
|
|
|
@@ -141,9 +141,9 @@ export function MobileMessageActionBar({
|
|
|
141
141
|
/>
|
|
142
142
|
}
|
|
143
143
|
onClick={act("star", onToggleStar)}
|
|
144
|
-
aria-label={isStarred ? "
|
|
144
|
+
aria-label={isStarred ? "Unstar" : "Star"}
|
|
145
145
|
aria-pressed={isStarred}
|
|
146
|
-
title={isStarred ? "
|
|
146
|
+
title={isStarred ? "Unstar" : "Star"}
|
|
147
147
|
className={TOUCH}
|
|
148
148
|
/>
|
|
149
149
|
{moveSlot}
|
|
@@ -87,7 +87,7 @@ describe("MobileReadingPane", () => {
|
|
|
87
87
|
// One expanded message ⇒ one Reply / Flag / Trash cluster; the two
|
|
88
88
|
// collapsed rows carry no bar.
|
|
89
89
|
assert.equal(countMatches(html, 'aria-label="Reply"'), 1);
|
|
90
|
-
assert.equal(countMatches(html, 'aria-label="
|
|
90
|
+
assert.equal(countMatches(html, 'aria-label="Star"'), 1);
|
|
91
91
|
assert.equal(countMatches(html, 'aria-label="Move to Trash"'), 1);
|
|
92
92
|
});
|
|
93
93
|
|
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
inboxFilterConfig,
|
|
7
7
|
} from "../filter-presets.js";
|
|
8
8
|
import { MobileSearchView } from "./mobile-search-view.js";
|
|
9
|
+
import type { SearchChip } from "./search-chip-input.js";
|
|
9
10
|
import type { SearchResult } from "./search-result-row.js";
|
|
10
11
|
import type { SearchResultSection } from "./search-results.js";
|
|
11
12
|
|
|
@@ -100,16 +101,19 @@ type Preset = "brief" | "inbox";
|
|
|
100
101
|
|
|
101
102
|
function Harness({
|
|
102
103
|
initialValue = "",
|
|
104
|
+
initialChips = [],
|
|
103
105
|
loading,
|
|
104
106
|
sections,
|
|
105
107
|
preset,
|
|
106
108
|
}: {
|
|
107
109
|
initialValue?: string;
|
|
110
|
+
initialChips?: SearchChip[];
|
|
108
111
|
loading?: boolean;
|
|
109
112
|
sections?: SearchResultSection[];
|
|
110
113
|
preset: Preset;
|
|
111
114
|
}) {
|
|
112
115
|
const [value, setValue] = useState(initialValue);
|
|
116
|
+
const [chips, setChips] = useState<SearchChip[]>(initialChips);
|
|
113
117
|
const [selectedCategory, setSelectedCategory] = useState("all");
|
|
114
118
|
const [activeFilters, setActiveFilters] = useState<Set<string>>(new Set());
|
|
115
119
|
const [activeSource, setActiveSource] = useState("personal");
|
|
@@ -152,6 +156,8 @@ function Harness({
|
|
|
152
156
|
onChange={setValue}
|
|
153
157
|
onClear={() => setValue("")}
|
|
154
158
|
onCancel={() => undefined}
|
|
159
|
+
chips={chips}
|
|
160
|
+
onRemoveChip={(id) => setChips((cs) => cs.filter((c) => c.id !== id))}
|
|
155
161
|
filter={{
|
|
156
162
|
...base,
|
|
157
163
|
selectedCategory,
|
|
@@ -245,3 +251,20 @@ export const RelatedSelectable: Story = {
|
|
|
245
251
|
/>
|
|
246
252
|
),
|
|
247
253
|
};
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* The narrowing expression on mobile: the same `SearchChipInput` the desktop
|
|
257
|
+
* top bar uses, inside the full-screen takeover's own chrome. The chip is
|
|
258
|
+
* removable in place — backspace at the start of the text reaches it just as it
|
|
259
|
+
* does on desktop.
|
|
260
|
+
*/
|
|
261
|
+
export const ScopedByChip: Story = {
|
|
262
|
+
render: () => (
|
|
263
|
+
<Harness
|
|
264
|
+
initialValue="invoice"
|
|
265
|
+
initialChips={[{ id: "in:spam", label: "in:spam" }]}
|
|
266
|
+
sections={resultSections}
|
|
267
|
+
preset="inbox"
|
|
268
|
+
/>
|
|
269
|
+
),
|
|
270
|
+
};
|
|
@@ -2,6 +2,7 @@ import { X } from "lucide-react";
|
|
|
2
2
|
import { Button } from "./button.js";
|
|
3
3
|
import { FilterSheet, type FilterSheetProps } from "./filter-sheet.js";
|
|
4
4
|
import { SearchBar } from "./search-bar.js";
|
|
5
|
+
import type { SearchChip } from "./search-chip-input.js";
|
|
5
6
|
import type { SearchResult } from "./search-result-row.js";
|
|
6
7
|
import { type SearchResultSection, SearchResults } from "./search-results.js";
|
|
7
8
|
|
|
@@ -30,6 +31,15 @@ export interface MobileSearchViewProps {
|
|
|
30
31
|
onSelectResult?: (result: SearchResult) => void;
|
|
31
32
|
/** Active filter-token chips parsed from the query; see `SearchResultsProps`. */
|
|
32
33
|
tokens?: { label: string; onRemove: () => void }[];
|
|
34
|
+
/**
|
|
35
|
+
* Narrowing terms rendered inline inside the search field, as part of the
|
|
36
|
+
* editable expression — the same `SearchChipInput` the desktop top bar uses.
|
|
37
|
+
* Supersedes `tokens` for chips the user can act on: `tokens` renders them as
|
|
38
|
+
* a static row above the results, `chips` puts them in the field where
|
|
39
|
+
* backspace and the caret keys reach them.
|
|
40
|
+
*/
|
|
41
|
+
chips?: readonly SearchChip[];
|
|
42
|
+
onRemoveChip?: (id: string) => void;
|
|
33
43
|
}
|
|
34
44
|
|
|
35
45
|
/**
|
|
@@ -55,6 +65,8 @@ export function MobileSearchView({
|
|
|
55
65
|
loading,
|
|
56
66
|
onSelectResult,
|
|
57
67
|
tokens,
|
|
68
|
+
chips,
|
|
69
|
+
onRemoveChip,
|
|
58
70
|
}: MobileSearchViewProps) {
|
|
59
71
|
const body = (
|
|
60
72
|
<SearchResults
|
|
@@ -76,6 +88,8 @@ export function MobileSearchView({
|
|
|
76
88
|
value={value}
|
|
77
89
|
onChange={onChange}
|
|
78
90
|
onClear={onClear}
|
|
91
|
+
chips={chips}
|
|
92
|
+
onRemoveChip={onRemoveChip}
|
|
79
93
|
globalFocusKey={false}
|
|
80
94
|
showClearButton={false}
|
|
81
95
|
/>
|
|
@@ -69,6 +69,18 @@ describe("NavSidebar", () => {
|
|
|
69
69
|
assert.match(html, /Daily brief/);
|
|
70
70
|
});
|
|
71
71
|
|
|
72
|
+
it("labels the \\Flagged virtual mailbox 'Starred'", () => {
|
|
73
|
+
const html = renderToString(
|
|
74
|
+
createElement(NavSidebar, {
|
|
75
|
+
accounts,
|
|
76
|
+
selectedNavId: "flagged",
|
|
77
|
+
onSelectNav: () => undefined,
|
|
78
|
+
}),
|
|
79
|
+
);
|
|
80
|
+
assert.match(html, /Starred/);
|
|
81
|
+
assert.doesNotMatch(html, /Flagged/);
|
|
82
|
+
});
|
|
83
|
+
|
|
72
84
|
it("renders system and custom mailbox names", () => {
|
|
73
85
|
const html = renderToString(
|
|
74
86
|
createElement(NavSidebar, {
|
|
@@ -580,8 +580,8 @@ export function NavSidebar({
|
|
|
580
580
|
navId="flagged"
|
|
581
581
|
linkComponent={linkComponent}
|
|
582
582
|
icon={<Star className="size-4" />}
|
|
583
|
-
label="
|
|
584
|
-
ariaLabel="
|
|
583
|
+
label="Starred"
|
|
584
|
+
ariaLabel="Starred"
|
|
585
585
|
active={selectedNavId === "flagged"}
|
|
586
586
|
onClick={() => onSelectNav?.("flagged")}
|
|
587
587
|
/>
|
|
@@ -53,3 +53,20 @@ describe("SearchBar", () => {
|
|
|
53
53
|
assert.match(html, /value="receipt"/);
|
|
54
54
|
});
|
|
55
55
|
});
|
|
56
|
+
|
|
57
|
+
describe("SearchBar narrowing chips", () => {
|
|
58
|
+
it("carries the chips inside the field, alongside the typed text", () => {
|
|
59
|
+
const html = renderToString(
|
|
60
|
+
createElement(SearchBar, {
|
|
61
|
+
value: "invoice",
|
|
62
|
+
onChange: noop,
|
|
63
|
+
onClear: noop,
|
|
64
|
+
chips: [{ id: "in:spam", label: "in:spam" }],
|
|
65
|
+
onRemoveChip: noop,
|
|
66
|
+
}),
|
|
67
|
+
);
|
|
68
|
+
assert.match(html, /in:spam/);
|
|
69
|
+
assert.match(html, /aria-label="Remove filter: in:spam"/);
|
|
70
|
+
assert.match(html, /value="invoice"/);
|
|
71
|
+
});
|
|
72
|
+
});
|
|
@@ -41,6 +41,8 @@ export const WithQuery: Story = {
|
|
|
41
41
|
export const Focused: Story = {
|
|
42
42
|
render: () => <Interactive />,
|
|
43
43
|
play: async ({ canvasElement }) => {
|
|
44
|
-
canvasElement
|
|
44
|
+
canvasElement
|
|
45
|
+
.querySelector<HTMLInputElement>('input[aria-label="Search mail"]')
|
|
46
|
+
?.focus();
|
|
45
47
|
},
|
|
46
48
|
};
|
|
@@ -1,6 +1,10 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
import {
|
|
2
|
+
type SearchChip,
|
|
3
|
+
SearchChipInput,
|
|
4
|
+
type SearchChipInputProps,
|
|
5
|
+
} from "./search-chip-input.js";
|
|
6
|
+
|
|
7
|
+
export type { SearchChip };
|
|
4
8
|
|
|
5
9
|
export interface SearchBarProps {
|
|
6
10
|
value: string;
|
|
@@ -16,6 +20,12 @@ export interface SearchBarProps {
|
|
|
16
20
|
* `onClear` when omitted.
|
|
17
21
|
*/
|
|
18
22
|
onClearQuery?: () => void;
|
|
23
|
+
/**
|
|
24
|
+
* Narrowing terms shown inline ahead of the typed text. Omit for a plain
|
|
25
|
+
* text field; see `SearchChipInput` for the expression semantics.
|
|
26
|
+
*/
|
|
27
|
+
chips?: readonly SearchChip[];
|
|
28
|
+
onRemoveChip?: (id: string) => void;
|
|
19
29
|
placeholder?: string;
|
|
20
30
|
/**
|
|
21
31
|
* Bind the global "/" shortcut that focuses the field from anywhere on the
|
|
@@ -28,91 +38,49 @@ export interface SearchBarProps {
|
|
|
28
38
|
* clear-and-close so there is exactly one X (Esc still clears the query).
|
|
29
39
|
*/
|
|
30
40
|
showClearButton?: boolean;
|
|
41
|
+
/** Field scale — `lg` for the global top bar. See `SearchChipInput`. */
|
|
42
|
+
size?: SearchChipInputProps["size"];
|
|
43
|
+
/**
|
|
44
|
+
* DOM id of the text input. Defaults to a generated, per-instance id; see
|
|
45
|
+
* `SearchChipInput`, which explains why a shared default breaks the field's
|
|
46
|
+
* enclosing label.
|
|
47
|
+
*/
|
|
48
|
+
inputId?: string;
|
|
49
|
+
className?: string;
|
|
31
50
|
}
|
|
32
51
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
);
|
|
40
|
-
};
|
|
41
|
-
|
|
52
|
+
/**
|
|
53
|
+
* The mail search field. A thin naming layer over `SearchChipInput`, which owns
|
|
54
|
+
* the chips-plus-text expression and its keyboard contract; every search
|
|
55
|
+
* surface (list header, mobile takeover, global top bar) renders this one
|
|
56
|
+
* field so the behaviour cannot drift between them.
|
|
57
|
+
*/
|
|
42
58
|
export const SearchBar = ({
|
|
43
59
|
value,
|
|
44
60
|
onChange,
|
|
45
61
|
onClear,
|
|
46
62
|
onClearQuery,
|
|
63
|
+
chips,
|
|
64
|
+
onRemoveChip,
|
|
47
65
|
placeholder = "Search mail...",
|
|
48
66
|
globalFocusKey = true,
|
|
49
67
|
showClearButton = true,
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
);
|
|
70
|
-
|
|
71
|
-
useEffect(() => {
|
|
72
|
-
if (!globalFocusKey) return;
|
|
73
|
-
const handleGlobalSlash = (event: KeyboardEvent) => {
|
|
74
|
-
if (event.key !== "/" || event.metaKey || event.ctrlKey || event.altKey) {
|
|
75
|
-
return;
|
|
76
|
-
}
|
|
77
|
-
if (isEditableTarget(event.target)) return;
|
|
78
|
-
event.preventDefault();
|
|
79
|
-
inputRef.current?.focus();
|
|
80
|
-
};
|
|
81
|
-
window.addEventListener("keydown", handleGlobalSlash);
|
|
82
|
-
return () => window.removeEventListener("keydown", handleGlobalSlash);
|
|
83
|
-
}, [globalFocusKey]);
|
|
84
|
-
|
|
85
|
-
return (
|
|
86
|
-
<div className="relative w-full">
|
|
87
|
-
<Search className="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-fg-muted pointer-events-none" />
|
|
88
|
-
<input
|
|
89
|
-
ref={inputRef}
|
|
90
|
-
id="mail-search"
|
|
91
|
-
name="q"
|
|
92
|
-
type="text"
|
|
93
|
-
aria-label="Search mail"
|
|
94
|
-
value={value}
|
|
95
|
-
onChange={(e) => onChange(e.target.value)}
|
|
96
|
-
onKeyDown={handleKeyDown}
|
|
97
|
-
placeholder={placeholder}
|
|
98
|
-
className={cn(
|
|
99
|
-
"w-full pl-9 pr-9 py-1.5 text-sm rounded-md",
|
|
100
|
-
"bg-surface-sunken/50 border border-transparent",
|
|
101
|
-
"focus:bg-canvas focus:border-line focus:outline-none focus:ring-2 focus:ring-ring",
|
|
102
|
-
"placeholder:text-fg-muted",
|
|
103
|
-
"transition-colors",
|
|
104
|
-
)}
|
|
105
|
-
/>
|
|
106
|
-
{value && showClearButton && (
|
|
107
|
-
<button
|
|
108
|
-
type="button"
|
|
109
|
-
onClick={handleClear}
|
|
110
|
-
className="absolute right-2 top-1/2 -translate-y-1/2 p-0.5 rounded hover:bg-surface-raised transition-colors"
|
|
111
|
-
aria-label="Clear search"
|
|
112
|
-
>
|
|
113
|
-
<X className="size-4 text-fg-muted" />
|
|
114
|
-
</button>
|
|
115
|
-
)}
|
|
116
|
-
</div>
|
|
117
|
-
);
|
|
118
|
-
};
|
|
68
|
+
size,
|
|
69
|
+
inputId,
|
|
70
|
+
className,
|
|
71
|
+
}: SearchBarProps) => (
|
|
72
|
+
<SearchChipInput
|
|
73
|
+
value={value}
|
|
74
|
+
onChange={onChange}
|
|
75
|
+
onClear={onClear}
|
|
76
|
+
onClearQuery={onClearQuery}
|
|
77
|
+
chips={chips}
|
|
78
|
+
onRemoveChip={onRemoveChip}
|
|
79
|
+
placeholder={placeholder}
|
|
80
|
+
globalFocusKey={globalFocusKey}
|
|
81
|
+
showClearButton={showClearButton}
|
|
82
|
+
size={size}
|
|
83
|
+
inputId={inputId}
|
|
84
|
+
className={className}
|
|
85
|
+
/>
|
|
86
|
+
);
|