@remit/ui 0.0.6 → 0.0.7
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
|
@@ -105,6 +105,37 @@ describe("AppShellSlotted slot rendering", () => {
|
|
|
105
105
|
);
|
|
106
106
|
});
|
|
107
107
|
|
|
108
|
+
it("renders the top bar above the panes, at every width", () => {
|
|
109
|
+
const topBarEl = createElement("div", { "data-testid": "topbar" }, "Bar");
|
|
110
|
+
for (const initialWidth of [800, 1100, 1400]) {
|
|
111
|
+
const html = render({ initialWidth, topBar: topBarEl });
|
|
112
|
+
assert.match(
|
|
113
|
+
html,
|
|
114
|
+
/data-testid="topbar"/,
|
|
115
|
+
`top bar at ${initialWidth}px`,
|
|
116
|
+
);
|
|
117
|
+
assert.ok(
|
|
118
|
+
html.indexOf('data-testid="topbar"') <
|
|
119
|
+
html.indexOf('data-testid="list"'),
|
|
120
|
+
`top bar precedes the pane group at ${initialWidth}px`,
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("omits the top bar when the caller passes none", () => {
|
|
126
|
+
const html = render({ initialWidth: 1100 });
|
|
127
|
+
assert.doesNotMatch(html, /data-testid="topbar"/);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("hides the top bar with the rest of the layout while loading", () => {
|
|
131
|
+
const html = render({
|
|
132
|
+
isLoading: true,
|
|
133
|
+
skeleton: createElement("div", { "data-testid": "skeleton" }, "Loading"),
|
|
134
|
+
topBar: createElement("div", { "data-testid": "topbar" }, "Bar"),
|
|
135
|
+
});
|
|
136
|
+
assert.doesNotMatch(html, /data-testid="topbar"/);
|
|
137
|
+
});
|
|
138
|
+
|
|
108
139
|
it("renders the overlay regardless of width", () => {
|
|
109
140
|
const overlayEl = createElement("div", { "data-testid": "overlay" }, "FAB");
|
|
110
141
|
const narrowHtml = render({ initialWidth: 800, overlay: overlayEl });
|
|
@@ -59,6 +59,13 @@ export interface AppShellSlottedProps {
|
|
|
59
59
|
* The web-client injects the app-specific bar (hamburger / title / search).
|
|
60
60
|
*/
|
|
61
61
|
header?: ReactNode;
|
|
62
|
+
/**
|
|
63
|
+
* Full-width row above every pane, including the nav. Unlike `header` it is
|
|
64
|
+
* rendered at whatever width the caller mounts it, and it spans the whole
|
|
65
|
+
* shell rather than sitting inside a pane — that width is what makes the
|
|
66
|
+
* search field in it read as the app's search rather than the list's.
|
|
67
|
+
*/
|
|
68
|
+
topBar?: ReactNode;
|
|
62
69
|
/**
|
|
63
70
|
* Content rendered outside the pane group (e.g., the compose FAB). Floats
|
|
64
71
|
* over the layout regardless of width.
|
|
@@ -130,6 +137,7 @@ export function AppShellSlotted({
|
|
|
130
137
|
hasThread = true,
|
|
131
138
|
density = "comfortable",
|
|
132
139
|
header,
|
|
140
|
+
topBar,
|
|
133
141
|
overlay,
|
|
134
142
|
skeleton,
|
|
135
143
|
isLoading = false,
|
|
@@ -178,6 +186,8 @@ export function AppShellSlotted({
|
|
|
178
186
|
skeleton
|
|
179
187
|
) : (
|
|
180
188
|
<>
|
|
189
|
+
{topBar}
|
|
190
|
+
|
|
181
191
|
{/* Narrow top bar: rendered only when the nav is a slide-over
|
|
182
192
|
(< 1024px). Desktop has no slim bar. */}
|
|
183
193
|
{!showNavPane && header && <div className="shrink-0">{header}</div>}
|
|
@@ -2,7 +2,9 @@ import assert from "node:assert/strict";
|
|
|
2
2
|
import { describe, it } from "node:test";
|
|
3
3
|
import { createElement } from "react";
|
|
4
4
|
import { renderToString } from "react-dom/server";
|
|
5
|
+
import { AppTopBar } from "./app-top-bar.js";
|
|
5
6
|
import { MailHeader, type MailHeaderProps } from "./mail-header.js";
|
|
7
|
+
import { SearchBar } from "./search-bar.js";
|
|
6
8
|
|
|
7
9
|
function render(overrides: Partial<MailHeaderProps> = {}): string {
|
|
8
10
|
return renderToString(
|
|
@@ -67,3 +69,58 @@ describe("MailHeader", () => {
|
|
|
67
69
|
assert.match(html, /Daily brief/);
|
|
68
70
|
});
|
|
69
71
|
});
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* One search field per page (#49).
|
|
75
|
+
*
|
|
76
|
+
* Moving search into `AppTopBar` puts a second `SearchBar` on the same screen
|
|
77
|
+
* as the list header's own. Two mounted fields compete for the "/" shortcut and
|
|
78
|
+
* for focus, and phase 1's review already found a pair colliding on a shared
|
|
79
|
+
* DOM id. `showSearch` is what holds the count at one, so the count is what
|
|
80
|
+
* these assert.
|
|
81
|
+
*/
|
|
82
|
+
describe("MailHeader search ownership", () => {
|
|
83
|
+
const countSearchInputs = (html: string): number =>
|
|
84
|
+
html.match(/aria-label="Search mail"/g)?.length ?? 0;
|
|
85
|
+
|
|
86
|
+
const topBar = (): string =>
|
|
87
|
+
renderToString(
|
|
88
|
+
createElement(AppTopBar, {
|
|
89
|
+
search: createElement(SearchBar, {
|
|
90
|
+
value: "",
|
|
91
|
+
onChange: () => undefined,
|
|
92
|
+
onClear: () => undefined,
|
|
93
|
+
size: "lg",
|
|
94
|
+
}),
|
|
95
|
+
}),
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
it("mounts no search field, and no magnifier, when showSearch is false", () => {
|
|
99
|
+
const html = render({ isDesktop: true, showSearch: false });
|
|
100
|
+
assert.equal(countSearchInputs(html), 0);
|
|
101
|
+
assert.doesNotMatch(html, /aria-label="Search"/);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("keeps the title and unread count when search lives elsewhere", () => {
|
|
105
|
+
const html = render({ isDesktop: true, showSearch: false });
|
|
106
|
+
assert.match(html, /Daily brief/);
|
|
107
|
+
assert.match(html, /15,338 unread/);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("suppresses the field below desktop too, where the takeover owns search", () => {
|
|
111
|
+
assert.equal(
|
|
112
|
+
countSearchInputs(render({ isDesktop: false, showSearch: false })),
|
|
113
|
+
0,
|
|
114
|
+
);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("leaves exactly one field on screen beside the app top bar", () => {
|
|
118
|
+
const combined = topBar() + render({ isDesktop: true, showSearch: false });
|
|
119
|
+
assert.equal(countSearchInputs(combined), 1);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("would be two fields if the header kept its own — the premise being guarded", () => {
|
|
123
|
+
const combined = topBar() + render({ isDesktop: true });
|
|
124
|
+
assert.equal(countSearchInputs(combined), 2);
|
|
125
|
+
});
|
|
126
|
+
});
|
|
@@ -22,6 +22,12 @@ export interface MailHeaderProps {
|
|
|
22
22
|
/** Mobile only: whether the inline search is expanded over the title. */
|
|
23
23
|
searchOpen: boolean;
|
|
24
24
|
onSearchOpenChange: (open: boolean) => void;
|
|
25
|
+
/**
|
|
26
|
+
* Whether this header owns a search surface at all. Defaults to true. Set
|
|
27
|
+
* false where an enclosing `AppTopBar` already carries the search field, so
|
|
28
|
+
* the page never mounts two search inputs competing for the same focus.
|
|
29
|
+
*/
|
|
30
|
+
showSearch?: boolean;
|
|
25
31
|
}
|
|
26
32
|
|
|
27
33
|
/**
|
|
@@ -44,6 +50,7 @@ export function MailHeader({
|
|
|
44
50
|
onSearchClear,
|
|
45
51
|
searchOpen,
|
|
46
52
|
onSearchOpenChange,
|
|
53
|
+
showSearch = true,
|
|
47
54
|
}: MailHeaderProps) {
|
|
48
55
|
const unreadLabel = `${unreadCount.toLocaleString()} unread`;
|
|
49
56
|
const clearSearch = onSearchClear ?? (() => onSearchChange(""));
|
|
@@ -75,7 +82,17 @@ export function MailHeader({
|
|
|
75
82
|
return (
|
|
76
83
|
<header className="flex shrink-0 flex-col bg-canvas">
|
|
77
84
|
<div className="flex h-12 items-center gap-2 px-row-inset">
|
|
78
|
-
{
|
|
85
|
+
{!showSearch ? (
|
|
86
|
+
<>
|
|
87
|
+
{menuButton}
|
|
88
|
+
<h1 className="min-w-0 flex-1 truncate text-sm font-semibold text-fg">
|
|
89
|
+
{title}
|
|
90
|
+
</h1>
|
|
91
|
+
<span className="shrink-0 text-2xs text-fg-subtle">
|
|
92
|
+
{unreadLabel}
|
|
93
|
+
</span>
|
|
94
|
+
</>
|
|
95
|
+
) : isDesktop ? (
|
|
79
96
|
<>
|
|
80
97
|
{menuButton}
|
|
81
98
|
<h1 className="min-w-0 flex-1 truncate text-sm font-semibold text-fg">
|