@remit/ui 0.0.30 → 0.0.32
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-slotted.render.test.ts +12 -0
- package/src/components/app-shell-slotted.tsx +89 -72
- package/src/components/app-shell.render.test.ts +4 -3
- package/src/components/app-top-bar.render.test.ts +11 -17
- package/src/components/app-top-bar.stories.tsx +16 -28
- package/src/components/app-top-bar.tsx +8 -19
- package/src/components/reading-pane.render.test.ts +3 -2
- package/src/components/reading-pane.tsx +9 -31
package/package.json
CHANGED
|
@@ -122,6 +122,18 @@ describe("AppShellSlotted slot rendering", () => {
|
|
|
122
122
|
}
|
|
123
123
|
});
|
|
124
124
|
|
|
125
|
+
it("keeps the nav column beside the top bar, not under it", () => {
|
|
126
|
+
const html = render({
|
|
127
|
+
initialWidth: 1400,
|
|
128
|
+
topBar: createElement("div", { "data-testid": "topbar" }, "Bar"),
|
|
129
|
+
reading: createElement("div", { "data-testid": "reading" }, "Reading"),
|
|
130
|
+
});
|
|
131
|
+
assert.ok(
|
|
132
|
+
html.indexOf('data-testid="nav"') < html.indexOf('data-testid="topbar"'),
|
|
133
|
+
"nav opens the layout so the bar starts on the list's edge",
|
|
134
|
+
);
|
|
135
|
+
});
|
|
136
|
+
|
|
125
137
|
it("omits the top bar when the caller passes none", () => {
|
|
126
138
|
const html = render({ initialWidth: 1100 });
|
|
127
139
|
assert.doesNotMatch(html, /data-testid="topbar"/);
|
|
@@ -60,10 +60,10 @@ export interface AppShellSlottedProps {
|
|
|
60
60
|
*/
|
|
61
61
|
header?: ReactNode;
|
|
62
62
|
/**
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
63
|
+
* Row above the list, reading and intelligence panes, spanning all three.
|
|
64
|
+
* The nav column runs the full height beside it and is never covered, so the
|
|
65
|
+
* bar starts on the list's left edge — its span is what makes the search
|
|
66
|
+
* field in it read as the app's search rather than the list's.
|
|
67
67
|
*/
|
|
68
68
|
topBar?: ReactNode;
|
|
69
69
|
/**
|
|
@@ -176,6 +176,65 @@ export function AppShellSlotted({
|
|
|
176
176
|
showIntelligencePane: isWide,
|
|
177
177
|
};
|
|
178
178
|
|
|
179
|
+
/* Sizes are percentages of the group they sit in. This group excludes the nav
|
|
180
|
+
column, so they are shares of the remaining ~83%, not of the whole shell. */
|
|
181
|
+
const contentPanes = (
|
|
182
|
+
<ResizablePanelGroup direction="horizontal" className="min-h-0 flex-1">
|
|
183
|
+
<ResizablePanel
|
|
184
|
+
id="list"
|
|
185
|
+
order={1}
|
|
186
|
+
defaultSize={showReadingPane ? (density === "compact" ? 43 : 33) : 100}
|
|
187
|
+
minSize={22}
|
|
188
|
+
maxSize={showReadingPane ? 58 : 100}
|
|
189
|
+
className="min-w-0"
|
|
190
|
+
>
|
|
191
|
+
{list}
|
|
192
|
+
</ResizablePanel>
|
|
193
|
+
|
|
194
|
+
{showReadingPane && (
|
|
195
|
+
<>
|
|
196
|
+
<ResizableHandle />
|
|
197
|
+
<ResizablePanel
|
|
198
|
+
id="reading"
|
|
199
|
+
order={2}
|
|
200
|
+
minSize={29}
|
|
201
|
+
className="min-w-0"
|
|
202
|
+
>
|
|
203
|
+
{reading}
|
|
204
|
+
</ResizablePanel>
|
|
205
|
+
</>
|
|
206
|
+
)}
|
|
207
|
+
|
|
208
|
+
{showIntelligencePanel && (
|
|
209
|
+
<>
|
|
210
|
+
<ResizableHandle />
|
|
211
|
+
<ResizablePanel
|
|
212
|
+
id="intelligence"
|
|
213
|
+
order={3}
|
|
214
|
+
defaultSize={25}
|
|
215
|
+
minSize={18}
|
|
216
|
+
maxSize={39}
|
|
217
|
+
className="min-w-0"
|
|
218
|
+
>
|
|
219
|
+
{intelligence}
|
|
220
|
+
</ResizablePanel>
|
|
221
|
+
</>
|
|
222
|
+
)}
|
|
223
|
+
</ResizablePanelGroup>
|
|
224
|
+
);
|
|
225
|
+
|
|
226
|
+
const content = (
|
|
227
|
+
<div className="flex min-h-0 min-w-0 flex-1 flex-col">
|
|
228
|
+
{topBar}
|
|
229
|
+
|
|
230
|
+
{/* Narrow top bar: rendered only when the nav is a slide-over
|
|
231
|
+
(< 1024px). Desktop has no slim bar. */}
|
|
232
|
+
{!showNavPane && header && <div className="shrink-0">{header}</div>}
|
|
233
|
+
|
|
234
|
+
{contentPanes}
|
|
235
|
+
</div>
|
|
236
|
+
);
|
|
237
|
+
|
|
179
238
|
return (
|
|
180
239
|
<AppShellLayoutCtx.Provider value={layoutCtx}>
|
|
181
240
|
<div
|
|
@@ -186,75 +245,33 @@ export function AppShellSlotted({
|
|
|
186
245
|
skeleton
|
|
187
246
|
) : (
|
|
188
247
|
<>
|
|
189
|
-
{
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
{!showNavPane && header && <div className="shrink-0">{header}</div>}
|
|
194
|
-
|
|
195
|
-
<ResizablePanelGroup
|
|
196
|
-
direction="horizontal"
|
|
197
|
-
className="min-h-0 flex-1"
|
|
198
|
-
>
|
|
199
|
-
{showNavPane && (
|
|
200
|
-
<>
|
|
201
|
-
<ResizablePanel
|
|
202
|
-
id="nav"
|
|
203
|
-
order={1}
|
|
204
|
-
defaultSize={17}
|
|
205
|
-
minSize={12}
|
|
206
|
-
maxSize={24}
|
|
207
|
-
className="min-w-0"
|
|
208
|
-
>
|
|
209
|
-
{nav}
|
|
210
|
-
</ResizablePanel>
|
|
211
|
-
<ResizableHandle />
|
|
212
|
-
</>
|
|
213
|
-
)}
|
|
214
|
-
|
|
215
|
-
<ResizablePanel
|
|
216
|
-
id="list"
|
|
217
|
-
order={2}
|
|
218
|
-
defaultSize={
|
|
219
|
-
showReadingPane ? (density === "compact" ? 36 : 27) : 83
|
|
220
|
-
}
|
|
221
|
-
minSize={18}
|
|
222
|
-
maxSize={showReadingPane ? 48 : 88}
|
|
223
|
-
className="min-w-0"
|
|
248
|
+
{showNavPane ? (
|
|
249
|
+
<ResizablePanelGroup
|
|
250
|
+
direction="horizontal"
|
|
251
|
+
className="min-h-0 flex-1"
|
|
224
252
|
>
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
order={4}
|
|
248
|
-
defaultSize={21}
|
|
249
|
-
minSize={15}
|
|
250
|
-
maxSize={32}
|
|
251
|
-
className="min-w-0"
|
|
252
|
-
>
|
|
253
|
-
{intelligence}
|
|
254
|
-
</ResizablePanel>
|
|
255
|
-
</>
|
|
256
|
-
)}
|
|
257
|
-
</ResizablePanelGroup>
|
|
253
|
+
<ResizablePanel
|
|
254
|
+
id="nav"
|
|
255
|
+
order={1}
|
|
256
|
+
defaultSize={17}
|
|
257
|
+
minSize={12}
|
|
258
|
+
maxSize={24}
|
|
259
|
+
className="min-w-0"
|
|
260
|
+
>
|
|
261
|
+
{nav}
|
|
262
|
+
</ResizablePanel>
|
|
263
|
+
<ResizableHandle />
|
|
264
|
+
<ResizablePanel
|
|
265
|
+
id="content"
|
|
266
|
+
order={2}
|
|
267
|
+
className="flex min-w-0 flex-col"
|
|
268
|
+
>
|
|
269
|
+
{content}
|
|
270
|
+
</ResizablePanel>
|
|
271
|
+
</ResizablePanelGroup>
|
|
272
|
+
) : (
|
|
273
|
+
content
|
|
274
|
+
)}
|
|
258
275
|
|
|
259
276
|
{/* Narrow nav: dismissible slide-over (#784). */}
|
|
260
277
|
{!showNavPane && (
|
|
@@ -109,8 +109,9 @@ describe("AppShell render honors the pane layout (#784)", () => {
|
|
|
109
109
|
thread,
|
|
110
110
|
intelligence,
|
|
111
111
|
};
|
|
112
|
-
// The reading
|
|
113
|
-
|
|
112
|
+
// The desktop reading-pane toolbar keeps the kit's default shortcut hints;
|
|
113
|
+
// the mobile pane passes its own labels without them.
|
|
114
|
+
const readingPaneMarker = /title="Reply \(r\)"/;
|
|
114
115
|
// "Known sender" is the wellknown trust label, rendered only by the rail.
|
|
115
116
|
const intelligenceMarker = /Known sender/;
|
|
116
117
|
|
|
@@ -175,7 +176,7 @@ describe("AppShell narrow message view: width-gated in-place swap", () => {
|
|
|
175
176
|
// narrow single pane is showing the message view.
|
|
176
177
|
const messageViewMarker = /aria-label="Back to messages"/;
|
|
177
178
|
// The desktop reading-pane toolbar — present only at/above 1024.
|
|
178
|
-
const readingPaneMarker = /
|
|
179
|
+
const readingPaneMarker = /title="Reply \(r\)"/;
|
|
179
180
|
|
|
180
181
|
it("below 1024 defaults to the list, not the message view", () => {
|
|
181
182
|
const html = render({ ...withThread, initialWidth: 800 });
|
|
@@ -20,33 +20,27 @@ describe("AppTopBar", () => {
|
|
|
20
20
|
assert.match(render(), /data-testid="search"/);
|
|
21
21
|
});
|
|
22
22
|
|
|
23
|
-
it("renders the
|
|
24
|
-
const html = render({
|
|
25
|
-
leading: slot("leading"),
|
|
26
|
-
actions: slot("actions"),
|
|
27
|
-
});
|
|
28
|
-
assert.match(html, /data-testid="leading"/);
|
|
23
|
+
it("renders the action slot when supplied", () => {
|
|
24
|
+
const html = render({ actions: slot("actions") });
|
|
29
25
|
assert.match(html, /data-testid="actions"/);
|
|
30
26
|
});
|
|
31
27
|
|
|
32
|
-
it("omits the
|
|
33
|
-
|
|
34
|
-
assert.doesNotMatch(html, /data-testid="leading"/);
|
|
35
|
-
assert.doesNotMatch(html, /data-testid="actions"/);
|
|
28
|
+
it("omits the actions entirely rather than leaving an empty box", () => {
|
|
29
|
+
assert.doesNotMatch(render(), /data-testid="actions"/);
|
|
36
30
|
});
|
|
37
31
|
|
|
38
|
-
it("lays the bar out
|
|
39
|
-
const html = render({
|
|
40
|
-
leading: slot("leading"),
|
|
41
|
-
actions: slot("actions"),
|
|
42
|
-
});
|
|
32
|
+
it("lays the bar out search · actions", () => {
|
|
33
|
+
const html = render({ actions: slot("actions") });
|
|
43
34
|
assert.ok(
|
|
44
|
-
html.indexOf("
|
|
45
|
-
html.indexOf("search") < html.indexOf("actions"),
|
|
35
|
+
html.indexOf("search") < html.indexOf("actions"),
|
|
46
36
|
"slots render in reading order",
|
|
47
37
|
);
|
|
48
38
|
});
|
|
49
39
|
|
|
40
|
+
it("carries no brand mark — the bar is search, not a masthead", () => {
|
|
41
|
+
assert.doesNotMatch(render(), /remit/i);
|
|
42
|
+
});
|
|
43
|
+
|
|
50
44
|
it("is a banner landmark spanning the app, not a pane header", () => {
|
|
51
45
|
assert.match(render(), /<header/);
|
|
52
46
|
assert.match(render(), /w-full/);
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
|
-
import { Bug,
|
|
2
|
+
import { Bug, SquarePen } from "lucide-react";
|
|
3
3
|
import { useState } from "react";
|
|
4
4
|
import { AppTopBar } from "./app-top-bar.js";
|
|
5
5
|
import { Avatar } from "./avatar.js";
|
|
@@ -15,20 +15,6 @@ export default meta;
|
|
|
15
15
|
|
|
16
16
|
type Story = StoryObj<typeof AppTopBar>;
|
|
17
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
18
|
const Actions = () => (
|
|
33
19
|
<>
|
|
34
20
|
<Button
|
|
@@ -74,7 +60,6 @@ const Bar = ({
|
|
|
74
60
|
const [value, setValue] = useState("");
|
|
75
61
|
return (
|
|
76
62
|
<AppTopBar
|
|
77
|
-
leading={<Brand />}
|
|
78
63
|
actions={<Actions />}
|
|
79
64
|
search={
|
|
80
65
|
<SearchChipInput
|
|
@@ -98,17 +83,19 @@ const Bar = ({
|
|
|
98
83
|
|
|
99
84
|
/** Over the panes it spans, so the arrangement reads the way it will in the app. */
|
|
100
85
|
const WithPanes = ({ children }: { children: React.ReactNode }) => (
|
|
101
|
-
<div className="flex h-96
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
<div className="
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
86
|
+
<div className="flex h-96 bg-canvas">
|
|
87
|
+
<div className="w-56 shrink-0 border-r border-line bg-surface p-3 text-xs text-fg-muted">
|
|
88
|
+
Nav — full height, beside the bar
|
|
89
|
+
</div>
|
|
90
|
+
<div className="flex min-w-0 flex-1 flex-col">
|
|
91
|
+
{children}
|
|
92
|
+
<div className="flex min-h-0 flex-1">
|
|
93
|
+
<div className="w-72 shrink-0 border-r border-line bg-surface p-3 text-xs text-fg-muted">
|
|
94
|
+
Message list
|
|
95
|
+
</div>
|
|
96
|
+
<div className="min-w-0 flex-1 p-3 text-xs text-fg-muted">
|
|
97
|
+
Message pane — its own toolbar lives here, under the bar
|
|
98
|
+
</div>
|
|
112
99
|
</div>
|
|
113
100
|
</div>
|
|
114
101
|
</div>
|
|
@@ -170,7 +157,8 @@ export const ScopedToOutbox: Story = {
|
|
|
170
157
|
),
|
|
171
158
|
};
|
|
172
159
|
|
|
173
|
-
/** The arrangement: one bar over the
|
|
160
|
+
/** The arrangement: one bar over the list and the message pane, lined up with
|
|
161
|
+
* the list's left edge, with the nav column running the full height beside it. */
|
|
174
162
|
export const OverTheLayout: Story = {
|
|
175
163
|
render: () => (
|
|
176
164
|
<WithPanes>
|
|
@@ -2,11 +2,6 @@ import type { ReactNode } from "react";
|
|
|
2
2
|
import { cn } from "../lib/cn.js";
|
|
3
3
|
|
|
4
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
5
|
/**
|
|
11
6
|
* The search field. Spans the bar's middle and is the only thing that
|
|
12
7
|
* grows, so the bar reads as one search surface for the whole app.
|
|
@@ -22,23 +17,20 @@ export interface AppTopBarProps {
|
|
|
22
17
|
}
|
|
23
18
|
|
|
24
19
|
/**
|
|
25
|
-
* The application top bar: one
|
|
26
|
-
* search and the global actions.
|
|
20
|
+
* The application top bar: one row over the list, reading and intelligence
|
|
21
|
+
* panes, carrying search and the global actions.
|
|
27
22
|
*
|
|
28
23
|
* 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
|
|
30
|
-
* says so.
|
|
31
|
-
*
|
|
24
|
+
* list's search — it reads across the whole app, and the bar's span is what
|
|
25
|
+
* says so. It starts on the list's left edge: the nav column runs the full
|
|
26
|
+
* height beside the bar rather than under it, so the field lines up with the
|
|
27
|
+
* columns it searches. The search field takes the room it needs, then the
|
|
28
|
+
* global actions.
|
|
32
29
|
*
|
|
33
30
|
* Presentational and slot-driven; the host supplies the wired field and
|
|
34
31
|
* action controls.
|
|
35
32
|
*/
|
|
36
|
-
export function AppTopBar({
|
|
37
|
-
leading,
|
|
38
|
-
search,
|
|
39
|
-
actions,
|
|
40
|
-
className,
|
|
41
|
-
}: AppTopBarProps) {
|
|
33
|
+
export function AppTopBar({ search, actions, className }: AppTopBarProps) {
|
|
42
34
|
return (
|
|
43
35
|
<header
|
|
44
36
|
className={cn(
|
|
@@ -48,9 +40,6 @@ export function AppTopBar({
|
|
|
48
40
|
className,
|
|
49
41
|
)}
|
|
50
42
|
>
|
|
51
|
-
{leading && (
|
|
52
|
-
<div className="flex shrink-0 items-center gap-2">{leading}</div>
|
|
53
|
-
)}
|
|
54
43
|
<div className="flex min-w-0 flex-1 justify-start">
|
|
55
44
|
<div className="w-full max-w-2xl">{search}</div>
|
|
56
45
|
</div>
|
|
@@ -53,9 +53,10 @@ describe("ReadingPane", () => {
|
|
|
53
53
|
assert.match(html, /Select a thread to read/);
|
|
54
54
|
});
|
|
55
55
|
|
|
56
|
-
it("renders the
|
|
56
|
+
it("renders the message verbs and no search field — search is the app top bar's", () => {
|
|
57
57
|
const html = renderToString(createElement(ReadingPane, { thread }));
|
|
58
|
-
assert.match(html, /
|
|
58
|
+
assert.match(html, /title="Reply \(r\)"/);
|
|
59
|
+
assert.doesNotMatch(html, /Search mail/);
|
|
59
60
|
});
|
|
60
61
|
});
|
|
61
62
|
|
|
@@ -1,17 +1,9 @@
|
|
|
1
|
-
import {
|
|
2
|
-
ChevronDown,
|
|
3
|
-
ChevronRight,
|
|
4
|
-
Search,
|
|
5
|
-
ShieldAlert,
|
|
6
|
-
SquarePen,
|
|
7
|
-
} from "lucide-react";
|
|
1
|
+
import { ChevronDown, ChevronRight, ShieldAlert } from "lucide-react";
|
|
8
2
|
import { type ReactNode, useState } from "react";
|
|
9
3
|
import { cn } from "../lib/cn.js";
|
|
10
4
|
import { isSelfRowActivation } from "../lib/row-keyboard.js";
|
|
11
5
|
import type { ThreadData, ThreadMessageData } from "./app-shell-types.js";
|
|
12
6
|
import { Avatar } from "./avatar.js";
|
|
13
|
-
import { Button } from "./button.js";
|
|
14
|
-
import { Input } from "./input.js";
|
|
15
7
|
import { IntelligenceToggle } from "./intelligence-toggle.js";
|
|
16
8
|
import { MailActionToolbar } from "./mail-action-toolbar.js";
|
|
17
9
|
import { MessageBodyView } from "./message-body-view.js";
|
|
@@ -252,13 +244,14 @@ export function ExpandedMessage({
|
|
|
252
244
|
}
|
|
253
245
|
|
|
254
246
|
/**
|
|
255
|
-
* Message action toolbar on the pane-header datum: the reading pane's
|
|
256
|
-
*
|
|
257
|
-
*
|
|
258
|
-
*
|
|
259
|
-
*
|
|
260
|
-
*
|
|
261
|
-
*
|
|
247
|
+
* Message action toolbar on the pane-header datum: the reading pane's verbs
|
|
248
|
+
* (reply/reply-all/forward, delete/move/flag) and the intelligence toggle.
|
|
249
|
+
* Everything here acts on the open message; search, compose and the account
|
|
250
|
+
* menu belong to the app and live in the `AppTopBar` above every pane. Built on
|
|
251
|
+
* the shared `MailActionToolbar` so the mail verbs stay pressable with no thread
|
|
252
|
+
* open and explain inline rather than greying out (the never-disable tenet). The
|
|
253
|
+
* intelligence toggle is the exception: it holds its slot and greys out when
|
|
254
|
+
* there is no rail to open (#52).
|
|
262
255
|
*/
|
|
263
256
|
function MessageToolbar({
|
|
264
257
|
hasThread,
|
|
@@ -278,21 +271,6 @@ function MessageToolbar({
|
|
|
278
271
|
onUnavailable={() => setHint("Open a message first")}
|
|
279
272
|
unavailableHint={hint}
|
|
280
273
|
>
|
|
281
|
-
{/* Apple Mail geometry: search sits top-right over the message
|
|
282
|
-
area but still filters the current list / brief */}
|
|
283
|
-
<Input
|
|
284
|
-
icon={<Search className="size-4" />}
|
|
285
|
-
placeholder="Search mail"
|
|
286
|
-
className="h-8 w-64 min-w-40 shrink"
|
|
287
|
-
/>
|
|
288
|
-
<span className="mx-1 h-4 w-px bg-line" aria-hidden />
|
|
289
|
-
<Button
|
|
290
|
-
variant="ghost"
|
|
291
|
-
size="sm"
|
|
292
|
-
icon={<SquarePen className="size-4" />}
|
|
293
|
-
title="Compose (⌘N)"
|
|
294
|
-
aria-label="Compose"
|
|
295
|
-
/>
|
|
296
274
|
<IntelligenceToggle
|
|
297
275
|
open={intelligenceOpen}
|
|
298
276
|
enabled={canToggleIntelligence}
|