@remit/ui 0.0.73 → 0.0.75
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 +3 -3
- package/src/components/app-shell-slotted.tsx +29 -7
- package/src/components/app-shell.render.test.ts +0 -1
- package/src/components/app-top-bar.render.test.ts +14 -3
- package/src/components/app-top-bar.stories.tsx +13 -15
- package/src/components/app-top-bar.tsx +24 -9
- package/src/components/brief-sections.tsx +63 -7
- package/src/components/dialog.render.test.ts +9 -13
- package/src/components/dialog.tsx +1 -2
- package/src/components/filter-sheet.render.test.ts +48 -1
- package/src/components/filter-sheet.tsx +111 -4
- package/src/components/mail-header.render.test.ts +14 -3
- package/src/components/mail-header.tsx +38 -27
- package/src/components/message-list-pane.tsx +55 -8
- package/src/components/mobile-search-view.render.test.ts +21 -0
- package/src/components/mobile-search-view.tsx +8 -2
- package/src/components/nav-sidebar.keyboard.test.ts +1 -2
- package/src/components/nav-sidebar.render.test.ts +13 -35
- package/src/components/nav-sidebar.tsx +0 -15
- package/src/components/nav-toggle-button.tsx +31 -0
- package/src/index.ts +5 -0
package/package.json
CHANGED
|
@@ -122,15 +122,15 @@ describe("AppShellSlotted slot rendering", () => {
|
|
|
122
122
|
}
|
|
123
123
|
});
|
|
124
124
|
|
|
125
|
-
it("
|
|
125
|
+
it("spans the top bar over the nav column too", () => {
|
|
126
126
|
const html = render({
|
|
127
127
|
initialWidth: 1400,
|
|
128
128
|
topBar: createElement("div", { "data-testid": "topbar" }, "Bar"),
|
|
129
129
|
reading: createElement("div", { "data-testid": "reading" }, "Reading"),
|
|
130
130
|
});
|
|
131
131
|
assert.ok(
|
|
132
|
-
html.indexOf('data-testid="
|
|
133
|
-
"
|
|
132
|
+
html.indexOf('data-testid="topbar"') < html.indexOf('data-testid="nav"'),
|
|
133
|
+
"the bar opens the layout, so it runs the full width",
|
|
134
134
|
);
|
|
135
135
|
});
|
|
136
136
|
|
|
@@ -60,10 +60,9 @@ export interface AppShellSlottedProps {
|
|
|
60
60
|
*/
|
|
61
61
|
header?: ReactNode;
|
|
62
62
|
/**
|
|
63
|
-
* Row
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
* field in it read as the app's search rather than the list's.
|
|
63
|
+
* Row across the top of the shell, spanning the nav, list, reading and
|
|
64
|
+
* intelligence panes. Its full width is what makes the search field in it
|
|
65
|
+
* read as the app's search rather than the list's.
|
|
67
66
|
*/
|
|
68
67
|
topBar?: ReactNode;
|
|
69
68
|
/**
|
|
@@ -108,6 +107,14 @@ export interface AppShellLayoutContext {
|
|
|
108
107
|
showNavPane: boolean;
|
|
109
108
|
/** Open the nav slide-over. Call from list-header "folders" buttons. */
|
|
110
109
|
openNav: () => void;
|
|
110
|
+
/** True when the nav pane is collapsed away at a width that would show it. */
|
|
111
|
+
navCollapsed: boolean;
|
|
112
|
+
/**
|
|
113
|
+
* Show or hide the nav at whichever tier is current — it collapses the pane
|
|
114
|
+
* where the nav is a column and opens the slide-over where it is not, so one
|
|
115
|
+
* control in the top bar covers both.
|
|
116
|
+
*/
|
|
117
|
+
toggleNav: () => void;
|
|
111
118
|
/** True when the reading pane is active (width ≥ 1024px). */
|
|
112
119
|
showReadingPane: boolean;
|
|
113
120
|
/** True when the intelligence rail can show (width ≥ 1280px). */
|
|
@@ -148,7 +155,11 @@ export function AppShellSlotted({
|
|
|
148
155
|
}: AppShellSlottedProps) {
|
|
149
156
|
const [containerRef, containerWidth] = useContainerWidth(initialWidth);
|
|
150
157
|
const panes = resolvePaneLayout(containerWidth ?? 0);
|
|
158
|
+
const [navCollapsed, setNavCollapsed] = useState(false);
|
|
159
|
+
// The tier, not the current visibility: consumers read it to decide whether
|
|
160
|
+
// nav access is theirs to offer, and it stays the top bar's while collapsed.
|
|
151
161
|
const showNavPane = panes.nav;
|
|
162
|
+
const navPaneVisible = showNavPane && !navCollapsed;
|
|
152
163
|
const showReadingPane = panes.reading && Boolean(reading);
|
|
153
164
|
const isWide = panes.intelligence;
|
|
154
165
|
|
|
@@ -167,11 +178,22 @@ export function AppShellSlotted({
|
|
|
167
178
|
const showIntelligencePanel =
|
|
168
179
|
isWide && intelligenceOpen && hasThread && Boolean(intelligence);
|
|
169
180
|
|
|
181
|
+
const toggleNav = () => {
|
|
182
|
+
if (!showNavPane) {
|
|
183
|
+
if (navOpen) closeNav();
|
|
184
|
+
else openNav();
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
setNavCollapsed((collapsed) => !collapsed);
|
|
188
|
+
};
|
|
189
|
+
|
|
170
190
|
const layoutCtx: AppShellLayoutContext = {
|
|
171
191
|
panes,
|
|
172
192
|
containerWidth,
|
|
173
193
|
showNavPane,
|
|
174
194
|
openNav,
|
|
195
|
+
navCollapsed,
|
|
196
|
+
toggleNav,
|
|
175
197
|
showReadingPane,
|
|
176
198
|
showIntelligencePane: isWide,
|
|
177
199
|
};
|
|
@@ -225,8 +247,6 @@ export function AppShellSlotted({
|
|
|
225
247
|
|
|
226
248
|
const content = (
|
|
227
249
|
<div className="flex min-h-0 min-w-0 flex-1 flex-col">
|
|
228
|
-
{topBar}
|
|
229
|
-
|
|
230
250
|
{/* Narrow top bar: rendered only when the nav is a slide-over
|
|
231
251
|
(< 1024px). Desktop has no slim bar. */}
|
|
232
252
|
{!showNavPane && header && <div className="shrink-0">{header}</div>}
|
|
@@ -245,7 +265,9 @@ export function AppShellSlotted({
|
|
|
245
265
|
skeleton
|
|
246
266
|
) : (
|
|
247
267
|
<>
|
|
248
|
-
{
|
|
268
|
+
{topBar}
|
|
269
|
+
|
|
270
|
+
{navPaneVisible ? (
|
|
249
271
|
<ResizablePanelGroup
|
|
250
272
|
direction="horizontal"
|
|
251
273
|
className="min-h-0 flex-1"
|
|
@@ -156,7 +156,6 @@ describe("AppShell nav: pane vs slide-over by width (#784)", () => {
|
|
|
156
156
|
it("≥1024px: nav is a persistent pane, no trigger", () => {
|
|
157
157
|
const html = render({ flatList: true, initialWidth: 1100 });
|
|
158
158
|
assert.match(html, navMarker, "nav renders as a pane");
|
|
159
|
-
assert.match(html, /Settings/, "the desktop nav pins a Settings footer");
|
|
160
159
|
assert.doesNotMatch(
|
|
161
160
|
html,
|
|
162
161
|
navTriggerMarker,
|
|
@@ -29,14 +29,25 @@ describe("AppTopBar", () => {
|
|
|
29
29
|
assert.doesNotMatch(render(), /data-testid="actions"/);
|
|
30
30
|
});
|
|
31
31
|
|
|
32
|
-
it("lays the bar out search · actions", () => {
|
|
33
|
-
const html = render({ actions: slot("actions") });
|
|
32
|
+
it("lays the bar out leading · search · actions", () => {
|
|
33
|
+
const html = render({ leading: slot("leading"), actions: slot("actions") });
|
|
34
34
|
assert.ok(
|
|
35
|
-
html.indexOf("
|
|
35
|
+
html.indexOf("leading") < html.indexOf("search") &&
|
|
36
|
+
html.indexOf("search") < html.indexOf("actions"),
|
|
36
37
|
"slots render in reading order",
|
|
37
38
|
);
|
|
38
39
|
});
|
|
39
40
|
|
|
41
|
+
it("omits the leading box when no control is supplied", () => {
|
|
42
|
+
assert.doesNotMatch(render(), /data-testid="leading"/);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("widens the field on focus rather than resting wide", () => {
|
|
46
|
+
const html = render();
|
|
47
|
+
assert.match(html, /max-w-xs/, "resting width is modest");
|
|
48
|
+
assert.match(html, /focus-within:max-w-lg/, "focus widens it");
|
|
49
|
+
});
|
|
50
|
+
|
|
40
51
|
it("carries no brand mark — the bar is search, not a masthead", () => {
|
|
41
52
|
assert.doesNotMatch(render(), /remit/i);
|
|
42
53
|
});
|
|
@@ -83,19 +83,17 @@ const Bar = ({
|
|
|
83
83
|
|
|
84
84
|
/** Over the panes it spans, so the arrangement reads the way it will in the app. */
|
|
85
85
|
const WithPanes = ({ children }: { children: React.ReactNode }) => (
|
|
86
|
-
<div className="flex h-96 bg-canvas">
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
<div className="
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
Message pane — its own toolbar lives here, under the bar
|
|
98
|
-
</div>
|
|
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
|
|
99
97
|
</div>
|
|
100
98
|
</div>
|
|
101
99
|
</div>
|
|
@@ -157,8 +155,8 @@ export const ScopedToOutbox: Story = {
|
|
|
157
155
|
),
|
|
158
156
|
};
|
|
159
157
|
|
|
160
|
-
/** The arrangement: one bar
|
|
161
|
-
*
|
|
158
|
+
/** The arrangement: one bar across the top of the shell, over the nav, the list
|
|
159
|
+
* and the message pane alike. */
|
|
162
160
|
export const OverTheLayout: Story = {
|
|
163
161
|
render: () => (
|
|
164
162
|
<WithPanes>
|
|
@@ -2,6 +2,11 @@ import type { ReactNode } from "react";
|
|
|
2
2
|
import { cn } from "../lib/cn.js";
|
|
3
3
|
|
|
4
4
|
export interface AppTopBarProps {
|
|
5
|
+
/**
|
|
6
|
+
* Controls at the bar's left edge, over the nav column — the nav toggle.
|
|
7
|
+
* Anything that acts on the shell itself rather than on the mail in it.
|
|
8
|
+
*/
|
|
9
|
+
leading?: ReactNode;
|
|
5
10
|
/**
|
|
6
11
|
* The search field. Spans the bar's middle and is the only thing that
|
|
7
12
|
* grows, so the bar reads as one search surface for the whole app.
|
|
@@ -17,31 +22,41 @@ export interface AppTopBarProps {
|
|
|
17
22
|
}
|
|
18
23
|
|
|
19
24
|
/**
|
|
20
|
-
* The application top bar: one row
|
|
21
|
-
* panes, carrying search and the global actions.
|
|
25
|
+
* The application top bar: one row across the top of the shell, over the nav,
|
|
26
|
+
* list, reading and intelligence panes, carrying search and the global actions.
|
|
22
27
|
*
|
|
23
28
|
* Search sits here rather than over the message list because it is not the
|
|
24
29
|
* list's search — it reads across the whole app, and the bar's span is what
|
|
25
|
-
* says so.
|
|
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.
|
|
30
|
+
* says so. The search field takes the room it needs, then the global actions.
|
|
29
31
|
*
|
|
30
32
|
* Presentational and slot-driven; the host supplies the wired field and
|
|
31
33
|
* action controls.
|
|
32
34
|
*/
|
|
33
|
-
export function AppTopBar({
|
|
35
|
+
export function AppTopBar({
|
|
36
|
+
leading,
|
|
37
|
+
search,
|
|
38
|
+
actions,
|
|
39
|
+
className,
|
|
40
|
+
}: AppTopBarProps) {
|
|
34
41
|
return (
|
|
35
42
|
<header
|
|
36
43
|
className={cn(
|
|
37
44
|
// min-h, not a fixed height: the search field grows when its chips
|
|
38
45
|
// wrap onto a second line.
|
|
39
|
-
"flex min-h-
|
|
46
|
+
"flex min-h-pane-header w-full shrink-0 items-center gap-2 border-b border-line bg-canvas px-row-inset py-1",
|
|
40
47
|
className,
|
|
41
48
|
)}
|
|
42
49
|
>
|
|
50
|
+
{leading && (
|
|
51
|
+
<div className="flex shrink-0 items-center gap-1">{leading}</div>
|
|
52
|
+
)}
|
|
43
53
|
<div className="flex min-w-0 flex-1 justify-start">
|
|
44
|
-
|
|
54
|
+
{/* Resting width is enough to read a query back, not enough to make an
|
|
55
|
+
empty field the loudest thing in the bar; focus widens it to where
|
|
56
|
+
a long query and its chips still fit on one line. */}
|
|
57
|
+
<div className="w-full max-w-xs transition-[max-width] duration-150 ease-out focus-within:max-w-lg">
|
|
58
|
+
{search}
|
|
59
|
+
</div>
|
|
45
60
|
</div>
|
|
46
61
|
{actions && (
|
|
47
62
|
<div className="flex shrink-0 items-center gap-1">{actions}</div>
|
|
@@ -16,7 +16,7 @@ import {
|
|
|
16
16
|
import type { BriefRowComponent } from "./message-row.js";
|
|
17
17
|
|
|
18
18
|
/* Composable brief filters — each is an additive predicate over a thread row. */
|
|
19
|
-
type BriefFilterId = "unread" | "attachment" | "contacts" | "today";
|
|
19
|
+
export type BriefFilterId = "unread" | "attachment" | "contacts" | "today";
|
|
20
20
|
|
|
21
21
|
/* "Today" prefers the real `sentDate` timestamp; it falls back to the fixture
|
|
22
22
|
convention that same-day rows render a HH:MM timeLabel (fixtures carry no
|
|
@@ -56,7 +56,40 @@ export const briefFilterChips: FilterSheetFilter[] = briefFilterDefs.map(
|
|
|
56
56
|
({ id, label }) => ({ id, label }),
|
|
57
57
|
);
|
|
58
58
|
|
|
59
|
-
|
|
59
|
+
/**
|
|
60
|
+
* Whether a thread survives a set of attribute chips, as the brief's own list
|
|
61
|
+
* applies them. Exported so a consumer narrowing the same rows on another
|
|
62
|
+
* surface — the phone search takeover — reads one definition of what "Unread" or
|
|
63
|
+
* "Today" means.
|
|
64
|
+
*/
|
|
65
|
+
export function matchesBriefFilters(
|
|
66
|
+
thread: ThreadRowData,
|
|
67
|
+
activeFilters: ReadonlySet<BriefFilterId>,
|
|
68
|
+
): boolean {
|
|
69
|
+
return briefFilterDefs.every(
|
|
70
|
+
(f) => !activeFilters.has(f.id) || f.match(thread),
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* The attribute chips are either this component's own or entirely the
|
|
76
|
+
* consumer's. A consumer narrowing the same rows on a second surface (the phone
|
|
77
|
+
* search takeover) holds the set so both surfaces answer to one selection, and
|
|
78
|
+
* takes every control over it with the set.
|
|
79
|
+
*/
|
|
80
|
+
type BriefFilterControl =
|
|
81
|
+
| {
|
|
82
|
+
activeFilters: ReadonlySet<BriefFilterId>;
|
|
83
|
+
onToggleFilter: (id: BriefFilterId) => void;
|
|
84
|
+
onClearFilters: () => void;
|
|
85
|
+
}
|
|
86
|
+
| {
|
|
87
|
+
activeFilters?: never;
|
|
88
|
+
onToggleFilter?: never;
|
|
89
|
+
onClearFilters?: never;
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
interface BriefSectionsBaseProps {
|
|
60
93
|
sections: ThreadSection[];
|
|
61
94
|
briefCategory?: BriefCategoryFilter;
|
|
62
95
|
selectedThreadId?: string;
|
|
@@ -73,10 +106,17 @@ export interface BriefSectionsProps {
|
|
|
73
106
|
sourcesNote?: string;
|
|
74
107
|
/** Called when the user selects a source/account pill. */
|
|
75
108
|
onSelectSource?: (id: string) => void;
|
|
109
|
+
/**
|
|
110
|
+
* Drop the filter row and its panel, keeping the rows where they are. See
|
|
111
|
+
* `FilterSheetProps`.
|
|
112
|
+
*/
|
|
113
|
+
hideChrome?: boolean;
|
|
76
114
|
/** Seeds the filter panel open on first render (stories / deep links). */
|
|
77
115
|
defaultExpanded?: boolean;
|
|
78
116
|
}
|
|
79
117
|
|
|
118
|
+
export type BriefSectionsProps = BriefSectionsBaseProps & BriefFilterControl;
|
|
119
|
+
|
|
80
120
|
/**
|
|
81
121
|
* The daily-brief list body: category pills (single-select) + attribute chips
|
|
82
122
|
* (additive) + one capped section per category (see {@link BriefSection}). Owns
|
|
@@ -95,9 +135,15 @@ export function BriefSections({
|
|
|
95
135
|
sources,
|
|
96
136
|
sourcesNote,
|
|
97
137
|
onSelectSource,
|
|
138
|
+
activeFilters,
|
|
139
|
+
onToggleFilter,
|
|
140
|
+
onClearFilters,
|
|
141
|
+
hideChrome,
|
|
98
142
|
defaultExpanded = false,
|
|
99
143
|
}: BriefSectionsProps) {
|
|
100
|
-
const [
|
|
144
|
+
const [ownFilters, setOwnFilters] = useState<ReadonlySet<BriefFilterId>>(
|
|
145
|
+
new Set(),
|
|
146
|
+
);
|
|
101
147
|
const [sheetExpanded, setSheetExpanded] = useState(defaultExpanded);
|
|
102
148
|
const listRef = useRef<HTMLDivElement>(null);
|
|
103
149
|
useRovingFocus({
|
|
@@ -105,8 +151,14 @@ export function BriefSections({
|
|
|
105
151
|
itemSelector: LIST_ROW_SELECTOR,
|
|
106
152
|
});
|
|
107
153
|
|
|
154
|
+
const active = activeFilters ?? ownFilters;
|
|
155
|
+
|
|
108
156
|
const toggleFilter = (id: BriefFilterId) => {
|
|
109
|
-
|
|
157
|
+
if (onToggleFilter) {
|
|
158
|
+
onToggleFilter(id);
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
setOwnFilters((prev) => {
|
|
110
162
|
const next = new Set(prev);
|
|
111
163
|
if (next.has(id)) next.delete(id);
|
|
112
164
|
else next.add(id);
|
|
@@ -114,10 +166,9 @@ export function BriefSections({
|
|
|
114
166
|
});
|
|
115
167
|
};
|
|
116
168
|
|
|
117
|
-
const predicates = briefFilterDefs.filter((f) => active.has(f.id));
|
|
118
169
|
const matches = (t: ThreadRowData) =>
|
|
119
170
|
(briefCategory === "all" || t.category === briefCategory) &&
|
|
120
|
-
|
|
171
|
+
matchesBriefFilters(t, active);
|
|
121
172
|
|
|
122
173
|
// One section per category only earns its keep at the "all" scope. Narrow to
|
|
123
174
|
// a single category and the headers are redundant: render a plain flat list.
|
|
@@ -142,7 +193,11 @@ export function BriefSections({
|
|
|
142
193
|
|
|
143
194
|
const clearFilters = () => {
|
|
144
195
|
onSelectBriefCategory?.("all");
|
|
145
|
-
|
|
196
|
+
if (onClearFilters) {
|
|
197
|
+
onClearFilters();
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
setOwnFilters(new Set());
|
|
146
201
|
};
|
|
147
202
|
|
|
148
203
|
const empty = showSections ? filtered.length === 0 : flatRows.length === 0;
|
|
@@ -200,6 +255,7 @@ export function BriefSections({
|
|
|
200
255
|
onSelectSource={onSelectSource}
|
|
201
256
|
onToggleFilter={(id) => toggleFilter(id as BriefFilterId)}
|
|
202
257
|
onClear={clearFilters}
|
|
258
|
+
hideChrome={hideChrome}
|
|
203
259
|
>
|
|
204
260
|
{listBody}
|
|
205
261
|
</FilterSheet>
|
|
@@ -24,23 +24,19 @@ describe("Dialog backdrop", () => {
|
|
|
24
24
|
assert.doesNotMatch(left, /backdrop-blur/, "left drawer has no blur");
|
|
25
25
|
});
|
|
26
26
|
|
|
27
|
-
it("dims
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
27
|
+
it("dims what it covers, at every anchor", () => {
|
|
28
|
+
for (const anchor of ["center", "left", "right"] as const) {
|
|
29
|
+
assert.match(
|
|
30
|
+
render(anchor === "center" ? {} : { anchor }),
|
|
31
|
+
/bg-canvas\/80/,
|
|
32
|
+
`${anchor} dialog dims the content behind it`,
|
|
33
|
+
);
|
|
34
|
+
}
|
|
34
35
|
});
|
|
35
36
|
|
|
36
|
-
it("right slide-over mirrors left
|
|
37
|
+
it("right slide-over mirrors left, pinned to the other edge", () => {
|
|
37
38
|
const right = render({ anchor: "right" });
|
|
38
39
|
assert.doesNotMatch(right, /backdrop-blur/, "right drawer has no blur");
|
|
39
|
-
assert.doesNotMatch(
|
|
40
|
-
right,
|
|
41
|
-
/bg-canvas\/80/,
|
|
42
|
-
"right drawer backdrop is transparent (no wash)",
|
|
43
|
-
);
|
|
44
40
|
assert.match(right, /justify-end/, "panel is pinned to the right edge");
|
|
45
41
|
assert.match(right, /border-l/, "right drawer has a left hairline");
|
|
46
42
|
});
|
|
@@ -62,7 +62,6 @@ export function Dialog({
|
|
|
62
62
|
|
|
63
63
|
const isLeft = anchor === "left";
|
|
64
64
|
const isRight = anchor === "right";
|
|
65
|
-
const isSlideOver = isLeft || isRight;
|
|
66
65
|
|
|
67
66
|
return (
|
|
68
67
|
// biome-ignore lint/a11y/noStaticElementInteractions: outer overlay closes dialog on click; role=presentation lets inner role=dialog own the AT semantics
|
|
@@ -78,7 +77,7 @@ export function Dialog({
|
|
|
78
77
|
role="presentation"
|
|
79
78
|
onClick={onClose}
|
|
80
79
|
>
|
|
81
|
-
<div className=
|
|
80
|
+
<div className="absolute inset-0 bg-canvas/80" />
|
|
82
81
|
<div
|
|
83
82
|
ref={dialogRef}
|
|
84
83
|
role="dialog"
|
|
@@ -2,7 +2,12 @@ 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 {
|
|
5
|
+
import {
|
|
6
|
+
FilterPanelProvider,
|
|
7
|
+
FilterSheet,
|
|
8
|
+
type FilterSheetProps,
|
|
9
|
+
FilterToggle,
|
|
10
|
+
} from "./filter-sheet.js";
|
|
6
11
|
|
|
7
12
|
const categories = [
|
|
8
13
|
{ id: "all", label: "All", tone: "neutral" as const },
|
|
@@ -74,3 +79,45 @@ describe("FilterSheet", () => {
|
|
|
74
79
|
assert.doesNotMatch(html, /role="slider"/);
|
|
75
80
|
});
|
|
76
81
|
});
|
|
82
|
+
|
|
83
|
+
describe("FilterSheet under a FilterPanelProvider", () => {
|
|
84
|
+
const underProvider = (overrides: Partial<FilterSheetProps> = {}) =>
|
|
85
|
+
renderToString(
|
|
86
|
+
createElement(
|
|
87
|
+
FilterPanelProvider,
|
|
88
|
+
null,
|
|
89
|
+
createElement(FilterToggle),
|
|
90
|
+
createElement(FilterSheet, {
|
|
91
|
+
categories,
|
|
92
|
+
filters,
|
|
93
|
+
sources,
|
|
94
|
+
selectedCategory: "all",
|
|
95
|
+
activeFilters: new Set<string>(),
|
|
96
|
+
onSelectCategory: () => undefined,
|
|
97
|
+
onSelectSource: () => undefined,
|
|
98
|
+
onToggleFilter: () => undefined,
|
|
99
|
+
onClear: () => undefined,
|
|
100
|
+
...overrides,
|
|
101
|
+
}),
|
|
102
|
+
),
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
it("spends no row of its own on a trigger — the header carries the caret", () => {
|
|
106
|
+
const html = underProvider();
|
|
107
|
+
assert.equal(
|
|
108
|
+
html.match(/aria-label="Expand filters"/g)?.length,
|
|
109
|
+
1,
|
|
110
|
+
"exactly one filter control on the page",
|
|
111
|
+
);
|
|
112
|
+
assert.doesNotMatch(html, /h-section-row/, "no trigger row");
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("starts collapsed even where a nearer component asks to be expanded", () => {
|
|
116
|
+
const html = underProvider({ expanded: true });
|
|
117
|
+
assert.doesNotMatch(html, /work@acme\.com/, "panel is closed");
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("renders nothing for a toggle with no panel above it", () => {
|
|
121
|
+
assert.equal(renderToString(createElement(FilterToggle)), "");
|
|
122
|
+
});
|
|
123
|
+
});
|
|
@@ -1,4 +1,12 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
createContext,
|
|
3
|
+
type ReactNode,
|
|
4
|
+
useCallback,
|
|
5
|
+
useContext,
|
|
6
|
+
useEffect,
|
|
7
|
+
useMemo,
|
|
8
|
+
useState,
|
|
9
|
+
} from "react";
|
|
2
10
|
import { cn } from "../lib/cn.js";
|
|
3
11
|
import { isSelfRowActivation } from "../lib/row-keyboard.js";
|
|
4
12
|
import { Badge } from "./badge.js";
|
|
@@ -107,6 +115,92 @@ function Close({ className }: { className?: string }) {
|
|
|
107
115
|
);
|
|
108
116
|
}
|
|
109
117
|
|
|
118
|
+
interface FilterPanelState {
|
|
119
|
+
open: boolean;
|
|
120
|
+
setOpen: (open: boolean) => void;
|
|
121
|
+
/** Whether the sheet under this provider is narrowing the list right now. */
|
|
122
|
+
active: boolean;
|
|
123
|
+
setActive: (active: boolean) => void;
|
|
124
|
+
/** Whether a sheet is mounted for the caret to open. */
|
|
125
|
+
hasSheet: boolean;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const FilterPanelCtx = createContext<FilterPanelState | null>(null);
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Shares one filter panel's open state between a list header and the sheet
|
|
132
|
+
* under it, which are siblings in the tree. Wrap both: the header renders a
|
|
133
|
+
* `FilterToggle`, the sheet drops its own trigger row, and the sheet keeps its
|
|
134
|
+
* filter state exactly where it already lives.
|
|
135
|
+
*
|
|
136
|
+
* A view whose body is sometimes something other than the filtered list — a
|
|
137
|
+
* skeleton, an empty state, an error — says so with `hasSheet`, and the caret
|
|
138
|
+
* stands down for as long as there is no panel behind it.
|
|
139
|
+
*/
|
|
140
|
+
export function FilterPanelProvider({
|
|
141
|
+
children,
|
|
142
|
+
hasSheet = true,
|
|
143
|
+
}: {
|
|
144
|
+
children: ReactNode;
|
|
145
|
+
hasSheet?: boolean;
|
|
146
|
+
}) {
|
|
147
|
+
const [open, setOpen] = useState(false);
|
|
148
|
+
const [active, setActive] = useState(false);
|
|
149
|
+
const value = useMemo(
|
|
150
|
+
() => ({ open, setOpen, active, setActive, hasSheet }),
|
|
151
|
+
[open, active, hasSheet],
|
|
152
|
+
);
|
|
153
|
+
return (
|
|
154
|
+
<FilterPanelCtx.Provider value={value}>{children}</FilterPanelCtx.Provider>
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Detaches whatever it wraps from an enclosing `FilterPanelProvider`. A
|
|
160
|
+
* full-screen surface that covers the list — the phone search takeover — carries
|
|
161
|
+
* its own filter chrome, and the caret it would otherwise borrow belongs to a
|
|
162
|
+
* header that is not on screen.
|
|
163
|
+
*/
|
|
164
|
+
export function FilterPanelBoundary({ children }: { children: ReactNode }) {
|
|
165
|
+
return (
|
|
166
|
+
<FilterPanelCtx.Provider value={null}>{children}</FilterPanelCtx.Provider>
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* The filter caret, for a list header to render beside its unread count. It is
|
|
172
|
+
* the view's whole filter affordance; the panel it opens still belongs to the
|
|
173
|
+
* sheet, inline above the rows.
|
|
174
|
+
*
|
|
175
|
+
* Renders nothing outside a `FilterPanelProvider`, and nothing while that
|
|
176
|
+
* provider reports no sheet — there is no panel to open.
|
|
177
|
+
*/
|
|
178
|
+
export function FilterToggle() {
|
|
179
|
+
const panel = useContext(FilterPanelCtx);
|
|
180
|
+
if (!panel?.hasSheet) return null;
|
|
181
|
+
const { open, active, setOpen } = panel;
|
|
182
|
+
return (
|
|
183
|
+
<button
|
|
184
|
+
type="button"
|
|
185
|
+
onClick={() => setOpen(!open)}
|
|
186
|
+
aria-expanded={open}
|
|
187
|
+
aria-label={open ? "Collapse filters" : "Expand filters"}
|
|
188
|
+
title="Filters"
|
|
189
|
+
className={cn(
|
|
190
|
+
"flex size-6 shrink-0 items-center justify-center rounded transition-colors hover:bg-surface-sunken",
|
|
191
|
+
active ? "text-accent-2" : "text-fg-subtle hover:text-fg-muted",
|
|
192
|
+
)}
|
|
193
|
+
>
|
|
194
|
+
<ChevronDown
|
|
195
|
+
className={cn(
|
|
196
|
+
"size-3 transition-transform duration-200",
|
|
197
|
+
open ? "rotate-180" : "rotate-0",
|
|
198
|
+
)}
|
|
199
|
+
/>
|
|
200
|
+
</button>
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
|
|
110
204
|
export function FilterSheet({
|
|
111
205
|
categories,
|
|
112
206
|
filters,
|
|
@@ -123,17 +217,21 @@ export function FilterSheet({
|
|
|
123
217
|
hideChrome = false,
|
|
124
218
|
children,
|
|
125
219
|
}: FilterSheetProps) {
|
|
220
|
+
const panel = useContext(FilterPanelCtx);
|
|
126
221
|
const isControlled = expandedProp !== undefined;
|
|
127
222
|
const [internalOpen, setInternalOpen] = useState(false);
|
|
128
223
|
|
|
129
|
-
|
|
224
|
+
// A provider is the composition owner opting the caret into a header above
|
|
225
|
+
// this sheet, so it outranks a nearer component's own expanded state.
|
|
226
|
+
const open = panel?.open ?? (isControlled ? expandedProp : internalOpen);
|
|
130
227
|
|
|
131
228
|
const setOpen = useCallback(
|
|
132
229
|
(next: boolean) => {
|
|
133
230
|
if (!isControlled) setInternalOpen(next);
|
|
231
|
+
panel?.setOpen(next);
|
|
134
232
|
onExpandedChange?.(next);
|
|
135
233
|
},
|
|
136
|
-
[isControlled, onExpandedChange],
|
|
234
|
+
[isControlled, onExpandedChange, panel],
|
|
137
235
|
);
|
|
138
236
|
|
|
139
237
|
const defaultCategory = categories[0];
|
|
@@ -150,6 +248,13 @@ export function FilterSheet({
|
|
|
150
248
|
activeFilters.size > 0 ||
|
|
151
249
|
(!isDefaultSource && !!activeSource);
|
|
152
250
|
|
|
251
|
+
const setPanelActive = panel?.setActive;
|
|
252
|
+
useEffect(() => {
|
|
253
|
+
if (!setPanelActive) return;
|
|
254
|
+
setPanelActive(hasActive);
|
|
255
|
+
return () => setPanelActive(false);
|
|
256
|
+
}, [hasActive, setPanelActive]);
|
|
257
|
+
|
|
153
258
|
const clearSource = useCallback(() => {
|
|
154
259
|
if (defaultSource) onSelectSource?.(defaultSource.id);
|
|
155
260
|
}, [defaultSource, onSelectSource]);
|
|
@@ -277,7 +382,9 @@ export function FilterSheet({
|
|
|
277
382
|
{/* The toggle is a div-button (not a real <button>) so the Clear
|
|
278
383
|
control can be a real nested <button> without invalid
|
|
279
384
|
button-in-button nesting. */}
|
|
280
|
-
{
|
|
385
|
+
{/* A `FilterPanelProvider` means the list header carries the caret, so
|
|
386
|
+
the sheet spends no row of its own on one. */}
|
|
387
|
+
{!hideChrome && !panel && (
|
|
281
388
|
// biome-ignore lint/a11y/useSemanticElements: nested <button> inside would be invalid button-in-button
|
|
282
389
|
<div
|
|
283
390
|
role="button"
|
|
@@ -28,9 +28,20 @@ describe("MailHeader", () => {
|
|
|
28
28
|
assert.match(html, /15,338 unread/);
|
|
29
29
|
});
|
|
30
30
|
|
|
31
|
-
it("renders the menu (hamburger) control", () => {
|
|
32
|
-
|
|
33
|
-
assert.match(render({
|
|
31
|
+
it("renders the menu (hamburger) control when it has somewhere to go", () => {
|
|
32
|
+
const onMenuClick = () => undefined;
|
|
33
|
+
assert.match(render({ onMenuClick }), /aria-label="Menu"/);
|
|
34
|
+
assert.match(render({ onMenuClick, isDesktop: true }), /aria-label="Menu"/);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("drops the hamburger where the nav is a pane, rather than leaving a dead control", () => {
|
|
38
|
+
assert.doesNotMatch(render(), /aria-label="Menu"/);
|
|
39
|
+
assert.doesNotMatch(render({ isDesktop: true }), /aria-label="Menu"/);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("sits on the pane-header datum once the top bar carries search", () => {
|
|
43
|
+
assert.match(render({ showSearch: false }), /h-pane-header/);
|
|
44
|
+
assert.match(render(), /h-section-row/);
|
|
34
45
|
});
|
|
35
46
|
|
|
36
47
|
it("does not render an account chip row (accounts live in the filter / nav)", () => {
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { Menu, Search, X } from "lucide-react";
|
|
2
|
+
import type { ReactNode } from "react";
|
|
3
|
+
import { cn } from "../lib/cn.js";
|
|
2
4
|
import { Button } from "./button.js";
|
|
3
5
|
import { SearchBar } from "./search-bar.js";
|
|
4
6
|
import type { SearchFieldSuggest } from "./search-chip-input.js";
|
|
@@ -29,6 +31,12 @@ export interface MailHeaderProps {
|
|
|
29
31
|
* the page never mounts two search inputs competing for the same focus.
|
|
30
32
|
*/
|
|
31
33
|
showSearch?: boolean;
|
|
34
|
+
/**
|
|
35
|
+
* The view's filter control, rendered beside the unread count — a
|
|
36
|
+
* `FilterToggle` over a `FilterSheet` under the same `FilterPanelProvider`.
|
|
37
|
+
* The header row already exists, so the filter costs the list no second row.
|
|
38
|
+
*/
|
|
39
|
+
filterToggle?: ReactNode;
|
|
32
40
|
/**
|
|
33
41
|
* Completions for what is being typed; see `SearchChipInput`. The list is
|
|
34
42
|
* not rendered here — the header is a fixed-height row, so the consumer
|
|
@@ -59,6 +67,7 @@ export function MailHeader({
|
|
|
59
67
|
searchOpen,
|
|
60
68
|
onSearchOpenChange,
|
|
61
69
|
showSearch = true,
|
|
70
|
+
filterToggle,
|
|
62
71
|
searchSuggest,
|
|
63
72
|
}: MailHeaderProps) {
|
|
64
73
|
const unreadLabel = `${unreadCount.toLocaleString()} unread`;
|
|
@@ -79,38 +88,46 @@ export function MailHeader({
|
|
|
79
88
|
/>
|
|
80
89
|
);
|
|
81
90
|
|
|
82
|
-
|
|
91
|
+
// Without its own field the row is a plain title bar and sits on the
|
|
92
|
+
// pane-header datum, so the control shrinks with it. Carrying the field means
|
|
93
|
+
// carrying touch targets too, which need the taller row.
|
|
94
|
+
const menuButton = onMenuClick && (
|
|
83
95
|
<Button
|
|
84
96
|
variant="ghost"
|
|
85
|
-
|
|
97
|
+
size={showSearch ? undefined : "sm"}
|
|
98
|
+
icon={<Menu className={showSearch ? "size-5" : "size-4"} />}
|
|
86
99
|
onClick={onMenuClick}
|
|
87
100
|
aria-label="Menu"
|
|
88
|
-
className=
|
|
101
|
+
className={
|
|
102
|
+
showSearch ? "min-h-11 min-w-11 shrink-0 px-0" : "-ml-1 shrink-0"
|
|
103
|
+
}
|
|
89
104
|
/>
|
|
90
105
|
);
|
|
91
106
|
|
|
107
|
+
const titleRow = (
|
|
108
|
+
<>
|
|
109
|
+
{menuButton}
|
|
110
|
+
<h1 className="min-w-0 flex-1 truncate text-sm font-semibold text-fg">
|
|
111
|
+
{title}
|
|
112
|
+
</h1>
|
|
113
|
+
<span className="shrink-0 text-2xs text-fg-subtle">{unreadLabel}</span>
|
|
114
|
+
{filterToggle}
|
|
115
|
+
</>
|
|
116
|
+
);
|
|
117
|
+
|
|
92
118
|
return (
|
|
93
119
|
<header className="flex shrink-0 flex-col bg-canvas">
|
|
94
|
-
<div
|
|
120
|
+
<div
|
|
121
|
+
className={cn(
|
|
122
|
+
"flex items-center gap-2 border-b border-line px-row-inset",
|
|
123
|
+
showSearch ? "h-section-row" : "h-pane-header",
|
|
124
|
+
)}
|
|
125
|
+
>
|
|
95
126
|
{!showSearch ? (
|
|
96
|
-
|
|
97
|
-
{menuButton}
|
|
98
|
-
<h1 className="min-w-0 flex-1 truncate text-sm font-semibold text-fg">
|
|
99
|
-
{title}
|
|
100
|
-
</h1>
|
|
101
|
-
<span className="shrink-0 text-2xs text-fg-subtle">
|
|
102
|
-
{unreadLabel}
|
|
103
|
-
</span>
|
|
104
|
-
</>
|
|
127
|
+
titleRow
|
|
105
128
|
) : isDesktop ? (
|
|
106
129
|
<>
|
|
107
|
-
{
|
|
108
|
-
<h1 className="min-w-0 flex-1 truncate text-sm font-semibold text-fg">
|
|
109
|
-
{title}
|
|
110
|
-
</h1>
|
|
111
|
-
<span className="shrink-0 text-2xs text-fg-subtle">
|
|
112
|
-
{unreadLabel}
|
|
113
|
-
</span>
|
|
130
|
+
{titleRow}
|
|
114
131
|
<div className="w-64 max-w-[40%] shrink-0">
|
|
115
132
|
{renderSearchBar(true)}
|
|
116
133
|
</div>
|
|
@@ -128,13 +145,7 @@ export function MailHeader({
|
|
|
128
145
|
</div>
|
|
129
146
|
) : (
|
|
130
147
|
<>
|
|
131
|
-
{
|
|
132
|
-
<h1 className="min-w-0 flex-1 truncate text-sm font-semibold text-fg">
|
|
133
|
-
{title}
|
|
134
|
-
</h1>
|
|
135
|
-
<span className="shrink-0 text-2xs text-fg-subtle">
|
|
136
|
-
{unreadLabel}
|
|
137
|
-
</span>
|
|
148
|
+
{titleRow}
|
|
138
149
|
<Button
|
|
139
150
|
variant="ghost"
|
|
140
151
|
icon={<Search className="size-5" />}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Menu } from "lucide-react";
|
|
2
2
|
import type { ReactNode } from "react";
|
|
3
|
-
import { useMemo, useRef, useState } from "react";
|
|
3
|
+
import { useCallback, useMemo, useRef, useState } from "react";
|
|
4
4
|
import { LIST_ROW_SELECTOR, useRovingFocus } from "../lib/roving-focus.js";
|
|
5
5
|
import type { AppShellProps, TouchSeed } from "./app-shell-types.js";
|
|
6
6
|
import { BriefSections } from "./brief-sections.js";
|
|
@@ -12,7 +12,12 @@ import {
|
|
|
12
12
|
type MessageListFilter,
|
|
13
13
|
MessageListLoading,
|
|
14
14
|
} from "./message-list-state.js";
|
|
15
|
-
import {
|
|
15
|
+
import {
|
|
16
|
+
type BriefRowComponent,
|
|
17
|
+
ComfortableRow,
|
|
18
|
+
CompactRow,
|
|
19
|
+
type RowToggleEvent,
|
|
20
|
+
} from "./message-row.js";
|
|
16
21
|
import { SelectionTopBar } from "./selection-top-bar.js";
|
|
17
22
|
import type { SwipePeek } from "./swipeable-row.js";
|
|
18
23
|
import { TouchListBody } from "./touch-list.js";
|
|
@@ -120,8 +125,10 @@ export function MessageListPane({
|
|
|
120
125
|
const [selectionMode, setSelectionMode] = useState(
|
|
121
126
|
initialTouchState === "selection",
|
|
122
127
|
);
|
|
128
|
+
// The seed is a touch-triage state; desktop starts unselected and gets there
|
|
129
|
+
// through the row checkboxes.
|
|
123
130
|
const [checkedIds, setCheckedIds] = useState<ReadonlySet<string>>(() =>
|
|
124
|
-
initialTouchState === "selection"
|
|
131
|
+
initialTouchState === "selection" && !isDesktop
|
|
125
132
|
? new Set(seededRows.slice(0, 2).map((t) => t.id))
|
|
126
133
|
: new Set(),
|
|
127
134
|
);
|
|
@@ -153,7 +160,7 @@ export function MessageListPane({
|
|
|
153
160
|
? "leading"
|
|
154
161
|
: undefined;
|
|
155
162
|
|
|
156
|
-
const toggleCheck = (id: string) => {
|
|
163
|
+
const toggleCheck = useCallback((id: string) => {
|
|
157
164
|
setCheckedIds((prev) => {
|
|
158
165
|
const next = new Set(prev);
|
|
159
166
|
if (next.has(id)) next.delete(id);
|
|
@@ -161,7 +168,7 @@ export function MessageListPane({
|
|
|
161
168
|
if (next.size === 0) setSelectionMode(false);
|
|
162
169
|
return next;
|
|
163
170
|
});
|
|
164
|
-
};
|
|
171
|
+
}, []);
|
|
165
172
|
const enterSelection = (id: string) => {
|
|
166
173
|
setSelectionMode(true);
|
|
167
174
|
setCheckedIds(new Set([id]));
|
|
@@ -183,10 +190,48 @@ export function MessageListPane({
|
|
|
183
190
|
setTimeout(() => setRefreshing(false), 1400);
|
|
184
191
|
};
|
|
185
192
|
|
|
193
|
+
// Desktop rows carry the checkbox the app's own rows have: it takes the
|
|
194
|
+
// avatar's place on hover, and checking one puts the pane in selection.
|
|
195
|
+
const desktopSelectable = isDesktop && !selectionBar && listState === "ready";
|
|
196
|
+
const rowSelection = useCallback(
|
|
197
|
+
(id: string) =>
|
|
198
|
+
desktopSelectable
|
|
199
|
+
? {
|
|
200
|
+
checked: checkedIds.has(id),
|
|
201
|
+
onToggle: (event: RowToggleEvent) => {
|
|
202
|
+
event.preventDefault();
|
|
203
|
+
event.stopPropagation();
|
|
204
|
+
toggleCheck(id);
|
|
205
|
+
},
|
|
206
|
+
}
|
|
207
|
+
: undefined,
|
|
208
|
+
[desktopSelectable, checkedIds, toggleCheck],
|
|
209
|
+
);
|
|
210
|
+
|
|
211
|
+
// The brief drives rows through a `BriefRowComponent`, whose props carry no
|
|
212
|
+
// selection — a consumer's own row (the web client's) wires its checkbox
|
|
213
|
+
// itself. Binding it here keeps the kit's rows selectable there too.
|
|
214
|
+
const BriefRow: BriefRowComponent = useCallback(
|
|
215
|
+
(props) => <Row {...props} selection={rowSelection(props.thread.id)} />,
|
|
216
|
+
[Row, rowSelection],
|
|
217
|
+
);
|
|
218
|
+
|
|
219
|
+
const selectableIds = seededRows.map((thread) => thread.id);
|
|
220
|
+
const allChecked =
|
|
221
|
+
selectableIds.length > 0 && selectableIds.every((id) => checkedIds.has(id));
|
|
222
|
+
const selectAll = {
|
|
223
|
+
checked: allChecked,
|
|
224
|
+
indeterminate: checkedIds.size > 0 && !allChecked,
|
|
225
|
+
onChange: () =>
|
|
226
|
+
setCheckedIds(allChecked ? new Set() : new Set(selectableIds)),
|
|
227
|
+
};
|
|
228
|
+
|
|
186
229
|
// When the caller supplies a selectionBar slot, it owns selection state.
|
|
187
|
-
// Fall back to the built-in
|
|
230
|
+
// Fall back to the built-in bar only when no external bar is given.
|
|
188
231
|
const inBuiltinSelection =
|
|
189
|
-
!selectionBar &&
|
|
232
|
+
!selectionBar &&
|
|
233
|
+
checkedIds.size > 0 &&
|
|
234
|
+
(desktopSelectable || (touchTriage && selectionMode));
|
|
190
235
|
|
|
191
236
|
return (
|
|
192
237
|
<section className="relative flex h-full w-full flex-col bg-surface">
|
|
@@ -195,6 +240,7 @@ export function MessageListPane({
|
|
|
195
240
|
<SelectionTopBar
|
|
196
241
|
title={listTitle}
|
|
197
242
|
count={checkedIds.size}
|
|
243
|
+
selectAll={desktopSelectable ? selectAll : undefined}
|
|
198
244
|
onCancel={cancelSelection}
|
|
199
245
|
onMarkRead={markCheckedRead}
|
|
200
246
|
onDelete={trashChecked}
|
|
@@ -246,7 +292,7 @@ export function MessageListPane({
|
|
|
246
292
|
sections={sections}
|
|
247
293
|
briefCategory={briefCategory}
|
|
248
294
|
selectedThreadId={selectedThreadId}
|
|
249
|
-
Row={
|
|
295
|
+
Row={BriefRow}
|
|
250
296
|
onSelectThread={onSelectThread}
|
|
251
297
|
onSelectBriefCategory={onSelectBriefCategory}
|
|
252
298
|
/>
|
|
@@ -291,6 +337,7 @@ export function MessageListPane({
|
|
|
291
337
|
key={thread.id}
|
|
292
338
|
thread={thread}
|
|
293
339
|
active={thread.id === selectedThreadId}
|
|
340
|
+
selection={rowSelection(thread.id)}
|
|
294
341
|
onClick={() => onSelectThread?.(thread.id)}
|
|
295
342
|
/>
|
|
296
343
|
))}
|
|
@@ -2,6 +2,7 @@ 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 { FilterPanelProvider } from "./filter-sheet.js";
|
|
5
6
|
import { MobileSearchView } from "./mobile-search-view.js";
|
|
6
7
|
import type { SearchResult } from "./search-result-row.js";
|
|
7
8
|
|
|
@@ -66,6 +67,26 @@ describe("MobileSearchView filter chrome", () => {
|
|
|
66
67
|
);
|
|
67
68
|
assert.match(html, /Expand filters/);
|
|
68
69
|
});
|
|
70
|
+
|
|
71
|
+
// The takeover covers the list whose header carries the caret, so it cannot
|
|
72
|
+
// borrow that caret: it renders over the header, not under it. Without the
|
|
73
|
+
// boundary the sheet reads the list's panel and drops its own trigger row,
|
|
74
|
+
// leaving the takeover with no way to open its filters at all.
|
|
75
|
+
it("keeps its own filter row over a list that has a filter panel", () => {
|
|
76
|
+
const html = renderToString(
|
|
77
|
+
createElement(
|
|
78
|
+
FilterPanelProvider,
|
|
79
|
+
null,
|
|
80
|
+
createElement(MobileSearchView, {
|
|
81
|
+
...base,
|
|
82
|
+
value: "",
|
|
83
|
+
sections: [],
|
|
84
|
+
filter,
|
|
85
|
+
}),
|
|
86
|
+
),
|
|
87
|
+
);
|
|
88
|
+
assert.match(html, /Expand filters/);
|
|
89
|
+
});
|
|
69
90
|
});
|
|
70
91
|
|
|
71
92
|
describe("MobileSearchView search scope", () => {
|
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
import { X } from "lucide-react";
|
|
2
2
|
import type { ReactNode } from "react";
|
|
3
3
|
import { Button } from "./button.js";
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
FilterPanelBoundary,
|
|
6
|
+
FilterSheet,
|
|
7
|
+
type FilterSheetProps,
|
|
8
|
+
} from "./filter-sheet.js";
|
|
5
9
|
import { SearchBar } from "./search-bar.js";
|
|
6
10
|
import type { SearchChip, SearchFieldSuggest } from "./search-chip-input.js";
|
|
7
11
|
import type { SearchResult } from "./search-result-row.js";
|
|
@@ -146,7 +150,9 @@ export function MobileSearchView({
|
|
|
146
150
|
{suggestList}
|
|
147
151
|
|
|
148
152
|
{filter && value.trim().length === 0 ? (
|
|
149
|
-
<
|
|
153
|
+
<FilterPanelBoundary>
|
|
154
|
+
<FilterSheet {...filter}>{body}</FilterSheet>
|
|
155
|
+
</FilterPanelBoundary>
|
|
150
156
|
) : (
|
|
151
157
|
<div className="flex-1 overflow-y-auto">{body}</div>
|
|
152
158
|
)}
|
|
@@ -112,14 +112,13 @@ describe("NavSidebar arrow-key traversal", () => {
|
|
|
112
112
|
assert.equal(dom.window.document.activeElement, items[1]);
|
|
113
113
|
});
|
|
114
114
|
|
|
115
|
-
it("End reaches the
|
|
115
|
+
it("End reaches the last mailbox and Home returns to the top", () => {
|
|
116
116
|
mount();
|
|
117
117
|
const items = navItems();
|
|
118
118
|
act(() => items[0]?.focus());
|
|
119
119
|
act(() => pressKey(items[0] as Element, "End"));
|
|
120
120
|
const last = items[items.length - 1];
|
|
121
121
|
assert.equal(dom.window.document.activeElement, last);
|
|
122
|
-
assert.match(last?.textContent ?? "", /Settings/);
|
|
123
122
|
|
|
124
123
|
act(() => pressKey(last as Element, "Home"));
|
|
125
124
|
assert.equal(dom.window.document.activeElement, items[0]);
|
|
@@ -107,41 +107,19 @@ describe("NavSidebar", () => {
|
|
|
107
107
|
assert.match(html, /text-accent-2/);
|
|
108
108
|
});
|
|
109
109
|
|
|
110
|
-
it("
|
|
111
|
-
const
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
const html = renderToString(
|
|
124
|
-
createElement(NavSidebar, {
|
|
125
|
-
accounts,
|
|
126
|
-
selectedNavId: "personal-inbox",
|
|
127
|
-
onSelectNav: () => undefined,
|
|
128
|
-
linkComponent: hrefLink,
|
|
129
|
-
}),
|
|
130
|
-
);
|
|
131
|
-
assert.match(html, /<a href="\/mail\/settings"/);
|
|
132
|
-
});
|
|
133
|
-
|
|
134
|
-
it("omits the Settings footer on the drawer variant", () => {
|
|
135
|
-
const html = renderToString(
|
|
136
|
-
createElement(NavSidebar, {
|
|
137
|
-
accounts,
|
|
138
|
-
selectedNavId: "personal-inbox",
|
|
139
|
-
onSelectNav: () => undefined,
|
|
140
|
-
variant: "drawer",
|
|
141
|
-
}),
|
|
142
|
-
);
|
|
143
|
-
assert.doesNotMatch(html, /Settings/);
|
|
144
|
-
assert.doesNotMatch(html, /lucide-settings/);
|
|
110
|
+
it("carries no Settings entry — it lives in the top bar, by the avatar", () => {
|
|
111
|
+
for (const variant of [undefined, "drawer" as const]) {
|
|
112
|
+
const html = renderToString(
|
|
113
|
+
createElement(NavSidebar, {
|
|
114
|
+
accounts,
|
|
115
|
+
selectedNavId: "personal-inbox",
|
|
116
|
+
onSelectNav: () => undefined,
|
|
117
|
+
variant,
|
|
118
|
+
}),
|
|
119
|
+
);
|
|
120
|
+
assert.doesNotMatch(html, /Settings/);
|
|
121
|
+
assert.doesNotMatch(html, /lucide-settings/);
|
|
122
|
+
}
|
|
145
123
|
});
|
|
146
124
|
|
|
147
125
|
it("renders navigation entries as anchors via linkComponent", () => {
|
|
@@ -12,7 +12,6 @@ import {
|
|
|
12
12
|
Mails,
|
|
13
13
|
Search,
|
|
14
14
|
Send,
|
|
15
|
-
Settings,
|
|
16
15
|
Sparkles,
|
|
17
16
|
Star,
|
|
18
17
|
Trash2,
|
|
@@ -648,21 +647,7 @@ export function NavSidebar({
|
|
|
648
647
|
ref={containerRef}
|
|
649
648
|
className="flex h-full w-full flex-col bg-surface-sunken"
|
|
650
649
|
>
|
|
651
|
-
{/* no toolbar over the sidebar (Apple Mail-style): nav content
|
|
652
|
-
starts at the top; the datum bar exists only over the
|
|
653
|
-
list/reading/intelligence panes */}
|
|
654
650
|
{navBody}
|
|
655
|
-
<div className="border-t border-line px-2 py-2">
|
|
656
|
-
<NavItem
|
|
657
|
-
navId="settings"
|
|
658
|
-
linkComponent={linkComponent}
|
|
659
|
-
icon={<Settings className="size-4" />}
|
|
660
|
-
label="Settings"
|
|
661
|
-
ariaLabel="Settings"
|
|
662
|
-
active={selectedNavId === "settings"}
|
|
663
|
-
onClick={() => onSelectNav?.("settings")}
|
|
664
|
-
/>
|
|
665
|
-
</div>
|
|
666
651
|
</aside>
|
|
667
652
|
);
|
|
668
653
|
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* NavToggleButton — the top bar's control over the nav column.
|
|
3
|
+
*
|
|
4
|
+
* One button for both tiers: it collapses and restores the nav pane where the
|
|
5
|
+
* nav is a column, and opens the slide-over where it is not. It sits in the top
|
|
6
|
+
* bar's leading slot, over the column it acts on, which is why no pane header
|
|
7
|
+
* carries a hamburger of its own at desktop widths.
|
|
8
|
+
*
|
|
9
|
+
* Renders nothing outside an `AppShellSlotted` — there is no nav to act on.
|
|
10
|
+
*/
|
|
11
|
+
import { Menu } from "lucide-react";
|
|
12
|
+
import { useAppShellLayout } from "./app-shell-slotted.js";
|
|
13
|
+
import { Button } from "./button.js";
|
|
14
|
+
|
|
15
|
+
export function NavToggleButton() {
|
|
16
|
+
const layout = useAppShellLayout();
|
|
17
|
+
if (!layout) return null;
|
|
18
|
+
const shown = layout.showNavPane ? !layout.navCollapsed : false;
|
|
19
|
+
return (
|
|
20
|
+
<Button
|
|
21
|
+
variant="ghost"
|
|
22
|
+
size="sm"
|
|
23
|
+
icon={<Menu className="size-4" />}
|
|
24
|
+
onClick={layout.toggleNav}
|
|
25
|
+
aria-label={shown ? "Hide folders" : "Show folders"}
|
|
26
|
+
aria-expanded={shown}
|
|
27
|
+
title="Folders"
|
|
28
|
+
className="shrink-0"
|
|
29
|
+
/>
|
|
30
|
+
);
|
|
31
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -86,8 +86,10 @@ export {
|
|
|
86
86
|
SECTION_ROW_CAP,
|
|
87
87
|
} from "./components/brief-section.js";
|
|
88
88
|
export {
|
|
89
|
+
type BriefFilterId,
|
|
89
90
|
BriefSections,
|
|
90
91
|
type BriefSectionsProps,
|
|
92
|
+
matchesBriefFilters,
|
|
91
93
|
} from "./components/brief-sections.js";
|
|
92
94
|
export {
|
|
93
95
|
Button,
|
|
@@ -188,11 +190,13 @@ export {
|
|
|
188
190
|
type FilterRuleEditorProps,
|
|
189
191
|
} from "./components/filter-rule-editor.js";
|
|
190
192
|
export {
|
|
193
|
+
FilterPanelProvider,
|
|
191
194
|
FilterSheet,
|
|
192
195
|
type FilterSheetCategory,
|
|
193
196
|
type FilterSheetFilter,
|
|
194
197
|
type FilterSheetProps,
|
|
195
198
|
type FilterSheetSource,
|
|
199
|
+
FilterToggle,
|
|
196
200
|
} from "./components/filter-sheet.js";
|
|
197
201
|
export {
|
|
198
202
|
FolderManageActions,
|
|
@@ -325,6 +329,7 @@ export {
|
|
|
325
329
|
NavSidebar,
|
|
326
330
|
type NavSidebarProps,
|
|
327
331
|
} from "./components/nav-sidebar.js";
|
|
332
|
+
export { NavToggleButton } from "./components/nav-toggle-button.js";
|
|
328
333
|
export {
|
|
329
334
|
NewFolderAction,
|
|
330
335
|
type NewFolderActionProps,
|