@remit/ui 0.0.72 → 0.0.74

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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/ui",
3
- "version": "0.0.72",
3
+ "version": "0.0.74",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "src"
@@ -122,15 +122,15 @@ describe("AppShellSlotted slot rendering", () => {
122
122
  }
123
123
  });
124
124
 
125
- it("keeps the nav column beside the top bar, not under 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="nav"') < html.indexOf('data-testid="topbar"'),
133
- "nav opens the layout so the bar starts on the list's edge",
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 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.
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
- {showNavPane ? (
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("search") < html.indexOf("actions"),
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
- <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>
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 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. */
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 over the list, reading and intelligence
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. 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.
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({ search, actions, className }: AppTopBarProps) {
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-16 w-full shrink-0 items-center gap-3 border-b border-line bg-canvas px-3 py-2",
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
- <div className="w-full max-w-2xl">{search}</div>
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>
@@ -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 with a plain scrim; left slide-over has no background wash", () => {
28
- assert.match(render({}), /bg-canvas\/80/, "center modal keeps a dim scrim");
29
- assert.doesNotMatch(
30
- render({ anchor: "left" }),
31
- /bg-canvas/,
32
- "left drawer backdrop is transparent (no wash)",
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: transparent backdrop, pinned right edge", () => {
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={cn("absolute inset-0", !isSlideOver && "bg-canvas/80")} />
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 { FilterSheet, type FilterSheetProps } from "./filter-sheet.js";
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 { useCallback, useState } from "react";
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,67 @@ 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
+ }
125
+
126
+ const FilterPanelCtx = createContext<FilterPanelState | null>(null);
127
+
128
+ /**
129
+ * Shares one filter panel's open state between a list header and the sheet
130
+ * under it, which are siblings in the tree. Wrap both: the header renders a
131
+ * `FilterToggle`, the sheet drops its own trigger row, and the sheet keeps its
132
+ * filter state exactly where it already lives.
133
+ */
134
+ export function FilterPanelProvider({ children }: { children: ReactNode }) {
135
+ const [open, setOpen] = useState(false);
136
+ const [active, setActive] = useState(false);
137
+ const value = useMemo(
138
+ () => ({ open, setOpen, active, setActive }),
139
+ [open, active],
140
+ );
141
+ return (
142
+ <FilterPanelCtx.Provider value={value}>{children}</FilterPanelCtx.Provider>
143
+ );
144
+ }
145
+
146
+ /**
147
+ * The filter caret, for a list header to render beside its unread count. It is
148
+ * the view's whole filter affordance; the panel it opens still belongs to the
149
+ * sheet, inline above the rows.
150
+ *
151
+ * Renders nothing outside a `FilterPanelProvider` — there is no panel to open.
152
+ */
153
+ export function FilterToggle() {
154
+ const panel = useContext(FilterPanelCtx);
155
+ if (!panel) return null;
156
+ const { open, active, setOpen } = panel;
157
+ return (
158
+ <button
159
+ type="button"
160
+ onClick={() => setOpen(!open)}
161
+ aria-expanded={open}
162
+ aria-label={open ? "Collapse filters" : "Expand filters"}
163
+ title="Filters"
164
+ className={cn(
165
+ "flex size-6 shrink-0 items-center justify-center rounded transition-colors hover:bg-surface-sunken",
166
+ active ? "text-accent-2" : "text-fg-subtle hover:text-fg-muted",
167
+ )}
168
+ >
169
+ <ChevronDown
170
+ className={cn(
171
+ "size-3 transition-transform duration-200",
172
+ open ? "rotate-180" : "rotate-0",
173
+ )}
174
+ />
175
+ </button>
176
+ );
177
+ }
178
+
110
179
  export function FilterSheet({
111
180
  categories,
112
181
  filters,
@@ -123,17 +192,21 @@ export function FilterSheet({
123
192
  hideChrome = false,
124
193
  children,
125
194
  }: FilterSheetProps) {
195
+ const panel = useContext(FilterPanelCtx);
126
196
  const isControlled = expandedProp !== undefined;
127
197
  const [internalOpen, setInternalOpen] = useState(false);
128
198
 
129
- const open = isControlled ? expandedProp : internalOpen;
199
+ // A provider is the composition owner opting the caret into a header above
200
+ // this sheet, so it outranks a nearer component's own expanded state.
201
+ const open = panel?.open ?? (isControlled ? expandedProp : internalOpen);
130
202
 
131
203
  const setOpen = useCallback(
132
204
  (next: boolean) => {
133
205
  if (!isControlled) setInternalOpen(next);
206
+ panel?.setOpen(next);
134
207
  onExpandedChange?.(next);
135
208
  },
136
- [isControlled, onExpandedChange],
209
+ [isControlled, onExpandedChange, panel],
137
210
  );
138
211
 
139
212
  const defaultCategory = categories[0];
@@ -150,6 +223,11 @@ export function FilterSheet({
150
223
  activeFilters.size > 0 ||
151
224
  (!isDefaultSource && !!activeSource);
152
225
 
226
+ const setPanelActive = panel?.setActive;
227
+ useEffect(() => {
228
+ setPanelActive?.(hasActive);
229
+ }, [hasActive, setPanelActive]);
230
+
153
231
  const clearSource = useCallback(() => {
154
232
  if (defaultSource) onSelectSource?.(defaultSource.id);
155
233
  }, [defaultSource, onSelectSource]);
@@ -277,7 +355,9 @@ export function FilterSheet({
277
355
  {/* The toggle is a div-button (not a real <button>) so the Clear
278
356
  control can be a real nested <button> without invalid
279
357
  button-in-button nesting. */}
280
- {!hideChrome && (
358
+ {/* A `FilterPanelProvider` means the list header carries the caret, so
359
+ the sheet spends no row of its own on one. */}
360
+ {!hideChrome && !panel && (
281
361
  // biome-ignore lint/a11y/useSemanticElements: nested <button> inside would be invalid button-in-button
282
362
  <div
283
363
  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
- assert.match(render(), /aria-label="Menu"/);
33
- assert.match(render({ isDesktop: true }), /aria-label="Menu"/);
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
- const menuButton = (
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
- icon={<Menu className="size-5" />}
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="min-h-11 min-w-11 shrink-0 px-0"
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 className="flex h-12 items-center gap-2 px-row-inset">
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
- {menuButton}
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
- {menuButton}
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 { ComfortableRow, CompactRow } from "./message-row.js";
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 touch-triage bar only when no external bar is given.
230
+ // Fall back to the built-in bar only when no external bar is given.
188
231
  const inBuiltinSelection =
189
- !selectionBar && touchTriage && selectionMode && checkedIds.size > 0;
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={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
  ))}
@@ -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 Settings footer and Home returns to the top", () => {
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("pins a Settings footer on the desktop variant", () => {
111
- const html = renderToString(
112
- createElement(NavSidebar, {
113
- accounts,
114
- selectedNavId: "personal-inbox",
115
- onSelectNav: () => undefined,
116
- }),
117
- );
118
- assert.match(html, /Settings/);
119
- assert.match(html, /lucide-settings/);
120
- });
121
-
122
- it("renders the Settings footer as an anchor via linkComponent", () => {
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
+ }
@@ -582,6 +582,25 @@ describe("RunStepBody", () => {
582
582
  assert.match(html, /Nothing has changed\./);
583
583
  });
584
584
 
585
+ // A poll that could not be read is not a run that never started (#526): the
586
+ // screen keeps the counts it has and says what it cannot see.
587
+ it("keeps a run that is going when its progress could not be read", () => {
588
+ const html = renderToString(
589
+ createElement(RunStepBody, {
590
+ ...runProps,
591
+ state: "statusUnknown",
592
+ scope: "once",
593
+ matched: 1284,
594
+ applied: 40,
595
+ }),
596
+ );
597
+ assert.match(html, /progress unknown/);
598
+ assert.match(html, /carries on either way/);
599
+ assert.match(html, /role="progressbar"/);
600
+ assert.doesNotMatch(html, /Nothing has changed/);
601
+ assert.doesNotMatch(html, /never started/);
602
+ });
603
+
585
604
  it("says a filter saved with nothing to back-apply is live", () => {
586
605
  const html = renderToString(
587
606
  createElement(RunStepBody, {
@@ -620,6 +639,18 @@ describe("RunFooter", () => {
620
639
  assert.match(html, /Not now/);
621
640
  });
622
641
 
642
+ it("offers another look, not another run, when the progress could not be read", () => {
643
+ const html = renderToString(
644
+ createElement(RunFooter, {
645
+ ...runProps,
646
+ state: "statusUnknown",
647
+ scope: "once",
648
+ }),
649
+ );
650
+ assert.match(html, /Check again/);
651
+ assert.match(html, /Close/);
652
+ });
653
+
623
654
  it("offers only a way out once there is nothing outstanding", () => {
624
655
  const html = renderToString(createElement(RunFooter, runProps));
625
656
  assert.match(html, /Done/);
package/src/index.ts CHANGED
@@ -188,11 +188,13 @@ export {
188
188
  type FilterRuleEditorProps,
189
189
  } from "./components/filter-rule-editor.js";
190
190
  export {
191
+ FilterPanelProvider,
191
192
  FilterSheet,
192
193
  type FilterSheetCategory,
193
194
  type FilterSheetFilter,
194
195
  type FilterSheetProps,
195
196
  type FilterSheetSource,
197
+ FilterToggle,
196
198
  } from "./components/filter-sheet.js";
197
199
  export {
198
200
  FolderManageActions,
@@ -325,6 +327,7 @@ export {
325
327
  NavSidebar,
326
328
  type NavSidebarProps,
327
329
  } from "./components/nav-sidebar.js";
330
+ export { NavToggleButton } from "./components/nav-toggle-button.js";
328
331
  export {
329
332
  NewFolderAction,
330
333
  type NewFolderActionProps,
@@ -477,6 +477,7 @@ describe("runCopy", () => {
477
477
  "backApplyComplete",
478
478
  "backApplyFailed",
479
479
  "backApplyStartFailed",
480
+ "statusUnknown",
480
481
  "filterSaved",
481
482
  "runStopped",
482
483
  "commitFailed",
@@ -539,6 +540,25 @@ describe("runCopy", () => {
539
540
  assert.equal(started.showProgress, false);
540
541
  });
541
542
 
543
+ it("keeps a run that is going when its progress could not be read", () => {
544
+ // A poll that failed says nothing about the job behind it (#526), so the
545
+ // screen never claims the action never started, and the way out of it is a
546
+ // second look rather than a second run.
547
+ const once = outcome("statusUnknown", "once");
548
+ assert.equal(once.title, "Moving — progress unknown");
549
+ assert.match(once.detail, /carries on either way/);
550
+ assert.doesNotMatch(once.detail, /Nothing has changed/);
551
+ assert.equal(once.tone, "warning");
552
+ assert.equal(once.retryLabel, "Check again");
553
+ assert.equal(once.screenTitle, "Move");
554
+
555
+ const standing = outcome("statusUnknown", "standing");
556
+ assert.match(standing.title, /Rule saved/);
557
+ assert.doesNotMatch(standing.detail, /never started/);
558
+ assert.match(standing.detail, /keeps working on new mail/);
559
+ assert.equal(standing.retryLabel, "Check again");
560
+ });
561
+
542
562
  it("says a filter saved with nothing to back-apply is still live", () => {
543
563
  const saved = outcome("filterSaved", "standing");
544
564
  assert.equal(saved.title, "Filter saved");
@@ -559,6 +579,8 @@ describe("runCopy", () => {
559
579
  assert.equal(outcome("backApplyRunning", "standing").showProgress, true);
560
580
  assert.equal(outcome("backApplyComplete", "once").showProgress, true);
561
581
  assert.equal(outcome("backApplyFailed", "once").showProgress, true);
582
+ // The last counts read are still the last counts read.
583
+ assert.equal(outcome("statusUnknown", "once").showProgress, true);
562
584
  });
563
585
 
564
586
  it("names the failure list in the verb's own past tense", () => {
@@ -301,6 +301,7 @@ export type RunState =
301
301
  | "backApplyComplete"
302
302
  | "backApplyFailed"
303
303
  | "backApplyStartFailed"
304
+ | "statusUnknown"
304
305
  | "filterSaved"
305
306
  | "runStopped"
306
307
  | "commitFailed";
@@ -374,7 +375,10 @@ export const runCopy = ({
374
375
  const { label, present, past } = verbCopy(verb);
375
376
  const done = past.toLowerCase();
376
377
  const standing = scope === "standing" || scope === "until";
377
- const inFlight = state === "saving" || state === "backApplyRunning";
378
+ const inFlight =
379
+ state === "saving" ||
380
+ state === "backApplyRunning" ||
381
+ state === "statusUnknown";
378
382
  const shared = {
379
383
  // A create that failed did not finish, so the header does not say it did.
380
384
  screenTitle: inFlight || state === "commitFailed" ? label : "Done",
@@ -382,6 +386,7 @@ export const runCopy = ({
382
386
  state === "backApplyRunning" ||
383
387
  state === "backApplyComplete" ||
384
388
  state === "backApplyFailed" ||
389
+ state === "statusUnknown" ||
385
390
  state === "runStopped",
386
391
  failureListLabel: `Not ${done}`,
387
392
  };
@@ -407,6 +412,20 @@ export const runCopy = ({
407
412
  cancelLabel: "Stop the run",
408
413
  };
409
414
  }
415
+ if (state === "statusUnknown") {
416
+ return {
417
+ ...shared,
418
+ title: standing
419
+ ? "Rule saved. Its progress over your existing mail is unknown"
420
+ : `${present} — progress unknown`,
421
+ detail: standing
422
+ ? "The connection dropped, so the bar shows the last that was read. The pass runs on the mail server, and the rule keeps working on new mail either way. This keeps checking."
423
+ : "The connection dropped, so the bar shows the last that was read. The run is on the mail server and carries on either way. This keeps checking.",
424
+ tone: "warning",
425
+ dismissLabel: "Close",
426
+ retryLabel: "Check again",
427
+ };
428
+ }
410
429
  if (state === "backApplyComplete") {
411
430
  return {
412
431
  ...shared,