@remit/ui 0.0.9 → 0.0.11
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-shell.tsx +1 -3
- package/src/components/intelligence-toggle.render.test.ts +37 -0
- package/src/components/intelligence-toggle.tsx +53 -0
- package/src/components/reading-pane.stories.tsx +1 -1
- package/src/components/reading-pane.tsx +15 -17
- package/src/components/search-results.stories.tsx +82 -0
- package/src/index.ts +4 -0
package/package.json
CHANGED
|
@@ -269,9 +269,7 @@ function AppShellReading({
|
|
|
269
269
|
thread={thread}
|
|
270
270
|
intelligenceOpen={showIntelligence}
|
|
271
271
|
onToggleIntelligence={onToggleIntelligence}
|
|
272
|
-
|
|
273
|
-
isWide && Boolean(intelligence) && Boolean(thread)
|
|
274
|
-
}
|
|
272
|
+
canToggleIntelligence={isWide && Boolean(intelligence) && Boolean(thread)}
|
|
275
273
|
/>
|
|
276
274
|
);
|
|
277
275
|
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Render invariants for the (i) intelligence toggle (#52): the control holds
|
|
3
|
+
* its slot in the toolbar on every view and in every selection state, and
|
|
4
|
+
* reports "cannot act" by being disabled rather than by disappearing.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import assert from "node:assert/strict";
|
|
8
|
+
import { describe, it } from "node:test";
|
|
9
|
+
import { createElement } from "react";
|
|
10
|
+
import { renderToString } from "react-dom/server";
|
|
11
|
+
import { IntelligenceToggle } from "./intelligence-toggle.js";
|
|
12
|
+
|
|
13
|
+
const render = (props: Parameters<typeof IntelligenceToggle>[0]): string =>
|
|
14
|
+
renderToString(createElement(IntelligenceToggle, props));
|
|
15
|
+
|
|
16
|
+
describe("IntelligenceToggle", () => {
|
|
17
|
+
it("renders the button when it cannot act", () => {
|
|
18
|
+
const html = render({ enabled: false });
|
|
19
|
+
assert.match(html, /aria-label="Show intelligence sidebar"/);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("disables rather than hides the button when it cannot act", () => {
|
|
23
|
+
const html = render({ enabled: false });
|
|
24
|
+
assert.match(html, /\sdisabled(=""|\s|>)/);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("is pressable when a thread is open and a rail exists", () => {
|
|
28
|
+
const html = render({ enabled: true });
|
|
29
|
+
assert.equal(/\sdisabled(=""|\s|>)/.test(html), false);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("stays rendered while the rail is open so it can close it again", () => {
|
|
33
|
+
const html = render({ enabled: true, open: true });
|
|
34
|
+
assert.match(html, /aria-label="Hide intelligence sidebar"/);
|
|
35
|
+
assert.match(html, /aria-pressed="true"/);
|
|
36
|
+
});
|
|
37
|
+
});
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { Info } from "lucide-react";
|
|
2
|
+
import { cn } from "../lib/cn.js";
|
|
3
|
+
import { Button } from "./button.js";
|
|
4
|
+
|
|
5
|
+
export interface IntelligenceToggleProps {
|
|
6
|
+
/** Whether the intelligence rail is currently open. */
|
|
7
|
+
open?: boolean;
|
|
8
|
+
/**
|
|
9
|
+
* Whether the toggle can act right now — a rail exists for this view, the
|
|
10
|
+
* width allows it, and a thread is open. When false the button still renders,
|
|
11
|
+
* greyed out (#52).
|
|
12
|
+
*/
|
|
13
|
+
enabled?: boolean;
|
|
14
|
+
onToggle?: () => void;
|
|
15
|
+
className?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The (i) intelligence-rail toggle in the reading-pane toolbar.
|
|
20
|
+
*
|
|
21
|
+
* The toolbar's control set is fixed: the toggle occupies the same slot on
|
|
22
|
+
* every view and in every selection state, and reports that it cannot act by
|
|
23
|
+
* being disabled rather than by vanishing (#52). A control that appears and
|
|
24
|
+
* disappears moves its neighbours and leaves the user with nothing to learn
|
|
25
|
+
* from; a greyed-out one stays where they last saw it.
|
|
26
|
+
*
|
|
27
|
+
* The title names the control rather than the reason it is off. There are three
|
|
28
|
+
* reasons (nothing selected, a window too narrow for the rail, a view with no
|
|
29
|
+
* rail), a disabled `Button` is `pointer-events-none` so no tooltip surfaces
|
|
30
|
+
* anyway, and a reason string that covers one case is wrong in the other two.
|
|
31
|
+
*/
|
|
32
|
+
export function IntelligenceToggle({
|
|
33
|
+
open = false,
|
|
34
|
+
enabled = true,
|
|
35
|
+
onToggle,
|
|
36
|
+
className,
|
|
37
|
+
}: IntelligenceToggleProps) {
|
|
38
|
+
return (
|
|
39
|
+
<Button
|
|
40
|
+
variant="ghost"
|
|
41
|
+
size="sm"
|
|
42
|
+
icon={<Info className="size-4" />}
|
|
43
|
+
title="Intelligence"
|
|
44
|
+
aria-label={
|
|
45
|
+
open ? "Hide intelligence sidebar" : "Show intelligence sidebar"
|
|
46
|
+
}
|
|
47
|
+
aria-pressed={open}
|
|
48
|
+
disabled={!enabled}
|
|
49
|
+
onClick={onToggle}
|
|
50
|
+
className={cn(open && "bg-accent-2-soft text-accent-2", className)}
|
|
51
|
+
/>
|
|
52
|
+
);
|
|
53
|
+
}
|
|
@@ -88,7 +88,7 @@ export const Newsletter: Story = { args: { thread: newsletterThread } };
|
|
|
88
88
|
export const Empty: Story = { args: { thread: undefined } };
|
|
89
89
|
|
|
90
90
|
export const WithIntelligenceToggle: Story = {
|
|
91
|
-
args: { thread,
|
|
91
|
+
args: { thread, canToggleIntelligence: true, intelligenceOpen: false },
|
|
92
92
|
};
|
|
93
93
|
|
|
94
94
|
/* ------------------------------------------------------------------ */
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import {
|
|
2
2
|
ChevronDown,
|
|
3
3
|
ChevronRight,
|
|
4
|
-
Info,
|
|
5
4
|
Search,
|
|
6
5
|
ShieldAlert,
|
|
7
6
|
SquarePen,
|
|
@@ -13,6 +12,7 @@ import type { ThreadData, ThreadMessageData } from "./app-shell-types.js";
|
|
|
13
12
|
import { Avatar } from "./avatar.js";
|
|
14
13
|
import { Button } from "./button.js";
|
|
15
14
|
import { Input } from "./input.js";
|
|
15
|
+
import { IntelligenceToggle } from "./intelligence-toggle.js";
|
|
16
16
|
import { MailActionToolbar } from "./mail-action-toolbar.js";
|
|
17
17
|
import { MessageBodyView } from "./message-body-view.js";
|
|
18
18
|
import { ReadingPaneEmpty } from "./reading-pane-empty.js";
|
|
@@ -248,19 +248,21 @@ export function ExpandedMessage({
|
|
|
248
248
|
* Message action toolbar on the pane-header datum: the reading pane's
|
|
249
249
|
* verbs (reply/reply-all/forward, delete/move/flag) plus search and
|
|
250
250
|
* compose, Apple Mail-style above the message area. Built on the shared
|
|
251
|
-
* `MailActionToolbar` so the
|
|
252
|
-
* explain inline rather than greying out (the never-disable
|
|
251
|
+
* `MailActionToolbar` so the mail verbs stay pressable with no
|
|
252
|
+
* thread open and explain inline rather than greying out (the never-disable
|
|
253
|
+
* tenet). The intelligence toggle is the exception: it holds its slot and greys
|
|
254
|
+
* out when there is no rail to open (#52).
|
|
253
255
|
*/
|
|
254
256
|
function MessageToolbar({
|
|
255
257
|
hasThread,
|
|
256
258
|
intelligenceOpen,
|
|
257
259
|
onToggleIntelligence,
|
|
258
|
-
|
|
260
|
+
canToggleIntelligence = false,
|
|
259
261
|
}: {
|
|
260
262
|
hasThread: boolean;
|
|
261
263
|
intelligenceOpen?: boolean;
|
|
262
264
|
onToggleIntelligence?: () => void;
|
|
263
|
-
|
|
265
|
+
canToggleIntelligence?: boolean;
|
|
264
266
|
}) {
|
|
265
267
|
const [hint, setHint] = useState<string | null>(null);
|
|
266
268
|
return (
|
|
@@ -284,15 +286,11 @@ function MessageToolbar({
|
|
|
284
286
|
title="Compose (⌘N)"
|
|
285
287
|
aria-label="Compose"
|
|
286
288
|
/>
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
onClick={onToggleIntelligence}
|
|
293
|
-
aria-label="Show intelligence sidebar"
|
|
294
|
-
/>
|
|
295
|
-
)}
|
|
289
|
+
<IntelligenceToggle
|
|
290
|
+
open={intelligenceOpen}
|
|
291
|
+
enabled={canToggleIntelligence}
|
|
292
|
+
onToggle={onToggleIntelligence}
|
|
293
|
+
/>
|
|
296
294
|
</MailActionToolbar>
|
|
297
295
|
);
|
|
298
296
|
}
|
|
@@ -301,12 +299,12 @@ export function ReadingPane({
|
|
|
301
299
|
thread,
|
|
302
300
|
intelligenceOpen,
|
|
303
301
|
onToggleIntelligence,
|
|
304
|
-
|
|
302
|
+
canToggleIntelligence,
|
|
305
303
|
}: {
|
|
306
304
|
thread?: ThreadData;
|
|
307
305
|
intelligenceOpen?: boolean;
|
|
308
306
|
onToggleIntelligence?: () => void;
|
|
309
|
-
|
|
307
|
+
canToggleIntelligence?: boolean;
|
|
310
308
|
}) {
|
|
311
309
|
return (
|
|
312
310
|
<article className="flex h-full w-full min-w-0 flex-col bg-canvas">
|
|
@@ -314,7 +312,7 @@ export function ReadingPane({
|
|
|
314
312
|
hasThread={Boolean(thread)}
|
|
315
313
|
intelligenceOpen={intelligenceOpen}
|
|
316
314
|
onToggleIntelligence={onToggleIntelligence}
|
|
317
|
-
|
|
315
|
+
canToggleIntelligence={canToggleIntelligence}
|
|
318
316
|
/>
|
|
319
317
|
|
|
320
318
|
{!thread ? (
|
|
@@ -72,6 +72,44 @@ const resultSections: SearchResultSection[] = [
|
|
|
72
72
|
{ id: "related", label: "Related", results: related },
|
|
73
73
|
];
|
|
74
74
|
|
|
75
|
+
/**
|
|
76
|
+
* Matches from outside the inbox — Archive, Sent, Spam and a custom folder.
|
|
77
|
+
* These are the rows an unscoped search returns that an INBOX-only one could
|
|
78
|
+
* not, so they only ever appear under the brief's sections.
|
|
79
|
+
*/
|
|
80
|
+
const crossFolderMatches: SearchResult[] = [
|
|
81
|
+
{
|
|
82
|
+
id: "x1",
|
|
83
|
+
sender: "Mollie",
|
|
84
|
+
subject: "Invoice 2026-02 — archived",
|
|
85
|
+
snippet: "Filed to Archive last month; payment already settled.",
|
|
86
|
+
date: "Feb 24",
|
|
87
|
+
category: { label: "Receipt", tone: "positive" },
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
id: "x2",
|
|
91
|
+
sender: "me",
|
|
92
|
+
subject: "Re: invoice query",
|
|
93
|
+
snippet: "Sent — attaching the invoice you asked for.",
|
|
94
|
+
date: "Feb 18",
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
id: "x3",
|
|
98
|
+
sender: "billing@unknown-vendor.test",
|
|
99
|
+
subject: "URGENT invoice attached",
|
|
100
|
+
snippet: "Marked as spam, but it is the invoice the user is looking for.",
|
|
101
|
+
date: "Feb 11",
|
|
102
|
+
category: { label: "Spam", tone: "warning" },
|
|
103
|
+
},
|
|
104
|
+
{
|
|
105
|
+
id: "x4",
|
|
106
|
+
sender: "Accountant",
|
|
107
|
+
subject: "Invoices for the quarter",
|
|
108
|
+
snippet: "Filed under Projects/Bookkeeping.",
|
|
109
|
+
date: "Jan 30",
|
|
110
|
+
},
|
|
111
|
+
];
|
|
112
|
+
|
|
75
113
|
const emptySections: SearchResultSection[] = [
|
|
76
114
|
{ id: "top", label: "Top matches", results: [] },
|
|
77
115
|
];
|
|
@@ -111,6 +149,50 @@ export const Idle: Story = {
|
|
|
111
149
|
render: () => <Harness value="" recentSearches={recentSearches} />,
|
|
112
150
|
};
|
|
113
151
|
|
|
152
|
+
/**
|
|
153
|
+
* The daily brief's unscoped search: no scope chip in the bar, and the literal
|
|
154
|
+
* section carries matches from every folder — Archive, Sent, Spam and custom
|
|
155
|
+
* folders alongside the inbox. Before the listing behind it took a search mode
|
|
156
|
+
* it could only ever return inbox mail, so this section was silently narrower
|
|
157
|
+
* than the bar promised.
|
|
158
|
+
*
|
|
159
|
+
* The rows themselves do not say which folder they came from; the sections are
|
|
160
|
+
* ordered newest first regardless of where each message is filed.
|
|
161
|
+
*/
|
|
162
|
+
export const UnscopedAcrossFolders: Story = {
|
|
163
|
+
render: () => (
|
|
164
|
+
<Harness
|
|
165
|
+
value="invoice"
|
|
166
|
+
sections={[
|
|
167
|
+
{
|
|
168
|
+
id: "top",
|
|
169
|
+
label: "Top matches",
|
|
170
|
+
results: [...topMatches, ...crossFolderMatches],
|
|
171
|
+
},
|
|
172
|
+
{ id: "related", label: "Related", results: related },
|
|
173
|
+
]}
|
|
174
|
+
/>
|
|
175
|
+
),
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* A scoped view (a mailbox route, its `in:` chip in the bar). Both sections are
|
|
180
|
+
* scoped to that folder and take the kit's default labels — the semantic
|
|
181
|
+
* section used to run unscoped here under an "Everywhere" heading, which
|
|
182
|
+
* contradicted the chip the same bar was showing.
|
|
183
|
+
*/
|
|
184
|
+
export const ScopedToOneFolder: Story = {
|
|
185
|
+
render: () => (
|
|
186
|
+
<Harness
|
|
187
|
+
value="invoice"
|
|
188
|
+
sections={[
|
|
189
|
+
{ id: "top", label: "Top matches", results: topMatches.slice(0, 2) },
|
|
190
|
+
{ id: "related", label: "Related", results: related.slice(0, 1) },
|
|
191
|
+
]}
|
|
192
|
+
/>
|
|
193
|
+
),
|
|
194
|
+
};
|
|
195
|
+
|
|
114
196
|
/** Typed filter tokens (`from:`, `has:attachment`, …) render as removable chips above the sections. */
|
|
115
197
|
export const WithFilterTokens: Story = {
|
|
116
198
|
render: () => (
|
package/src/index.ts
CHANGED
|
@@ -144,6 +144,10 @@ export {
|
|
|
144
144
|
type SimilarMessageLinkProps,
|
|
145
145
|
type SimilarState,
|
|
146
146
|
} from "./components/intelligence-panel.js";
|
|
147
|
+
export {
|
|
148
|
+
IntelligenceToggle,
|
|
149
|
+
type IntelligenceToggleProps,
|
|
150
|
+
} from "./components/intelligence-toggle.js";
|
|
147
151
|
export {
|
|
148
152
|
IsolatedEmailFrame,
|
|
149
153
|
type IsolatedEmailFrameProps,
|