@remit/ui 0.0.79 → 0.0.80

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.79",
3
+ "version": "0.0.80",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "src"
@@ -213,6 +213,15 @@ export const briefCategories: ReadonlyArray<{
213
213
  { id: "social", label: "Social" },
214
214
  ];
215
215
 
216
+ /**
217
+ * Whether an id names one of the brief's category scopes. A host holding one
218
+ * category across views whose sheets speak plain strings narrows it with this
219
+ * rather than asserting it.
220
+ */
221
+ export function isBriefCategory(id: string): id is BriefCategoryFilter {
222
+ return briefCategories.some((c) => c.id === id);
223
+ }
224
+
216
225
  export interface ThreadRowLabel {
217
226
  labelId: string;
218
227
  name: string;
@@ -244,11 +244,10 @@ function AppShellList({
244
244
  searchQuery={searchQuery}
245
245
  onRetry={onRetry}
246
246
  onReportError={onReportError}
247
- briefCategory={briefCategory}
247
+ briefFilter={{ briefCategory, onSelectBriefCategory }}
248
248
  selectedThreadId={selectedThreadId}
249
249
  density={density}
250
250
  onSelectThread={onSelectThread}
251
- onSelectBriefCategory={onSelectBriefCategory}
252
251
  onOpenNav={showNavPane ? undefined : layout?.openNav}
253
252
  isDesktop={showReadingPane}
254
253
  initialTouchState={initialTouchState}
@@ -56,6 +56,16 @@ export const briefFilterChips: FilterSheetFilter[] = briefFilterDefs.map(
56
56
  ({ id, label }) => ({ id, label }),
57
57
  );
58
58
 
59
+ /**
60
+ * Whether an id names one of the brief's attribute chips. A consumer holding
61
+ * one filter set across several views — the workbench shell, whose mailbox
62
+ * sheet offers chips of its own — narrows that set to the brief's own with
63
+ * this rather than asserting it.
64
+ */
65
+ export function isBriefFilterId(id: string): id is BriefFilterId {
66
+ return briefFilterDefs.some((f) => f.id === id);
67
+ }
68
+
59
69
  /**
60
70
  * Whether a thread survives a set of attribute chips, as the brief's own list
61
71
  * applies them. Exported so a consumer narrowing the same rows on another
@@ -77,7 +87,7 @@ export function matchesBriefFilters(
77
87
  * search takeover) holds the set so both surfaces answer to one selection, and
78
88
  * takes every control over it with the set.
79
89
  */
80
- type BriefFilterControl =
90
+ export type BriefFilterControl =
81
91
  | {
82
92
  activeFilters: ReadonlySet<BriefFilterId>;
83
93
  onToggleFilter: (id: BriefFilterId) => void;
@@ -89,13 +99,8 @@ type BriefFilterControl =
89
99
  onClearFilters?: never;
90
100
  };
91
101
 
92
- interface BriefSectionsBaseProps {
93
- sections: ThreadSection[];
94
- briefCategory?: BriefCategoryFilter;
95
- selectedThreadId?: string;
96
- Row: BriefRowComponent;
97
- onSelectThread?: (id: string) => void;
98
- onSelectBriefCategory?: (category: BriefCategoryFilter) => void;
102
+ /** The accounts the aggregate is segmented by, as the FilterSheet draws them. */
103
+ export interface BriefSourceControl {
99
104
  /**
100
105
  * Account/source pills, passed straight through to the FilterSheet. Selection
101
106
  * is encoded per source via `active`; the row only renders when more than one
@@ -106,6 +111,31 @@ interface BriefSectionsBaseProps {
106
111
  sourcesNote?: string;
107
112
  /** Called when the user selects a source/account pill. */
108
113
  onSelectSource?: (id: string) => void;
114
+ }
115
+
116
+ /** The single category the sections are scoped to. */
117
+ export interface BriefCategoryControl {
118
+ briefCategory?: BriefCategoryFilter;
119
+ onSelectBriefCategory?: (category: BriefCategoryFilter) => void;
120
+ }
121
+
122
+ /**
123
+ * The brief's whole filter surface as a host holds it: the category scope, the
124
+ * account pills and the attribute chips. A host with a second surface over the
125
+ * same rows — the phone search takeover — holds this one set and hands it to
126
+ * both, so a filter set on either is set on both.
127
+ */
128
+ export type BriefFilterSurface = BriefCategoryControl &
129
+ BriefSourceControl &
130
+ BriefFilterControl;
131
+
132
+ interface BriefSectionsBaseProps
133
+ extends BriefCategoryControl,
134
+ BriefSourceControl {
135
+ sections: ThreadSection[];
136
+ selectedThreadId?: string;
137
+ Row: BriefRowComponent;
138
+ onSelectThread?: (id: string) => void;
109
139
  /**
110
140
  * Drop the filter row and its panel, keeping the rows where they are. See
111
141
  * `FilterSheetProps`.
@@ -138,19 +138,35 @@ const FilterPanelCtx = createContext<FilterPanelState | null>(null);
138
138
  * body is sometimes something else — a skeleton, an empty state, an error —
139
139
  * would otherwise inherit a caret over nothing by saying nothing. Compute it
140
140
  * from what the body renders, in the same pass that renders it.
141
+ *
142
+ * `open`/`onOpenChange` hand the panel's state to the host, for a view whose
143
+ * second surface — the phone search takeover — replaces this provider while it
144
+ * is up. Omit both and the provider keeps the state itself.
141
145
  */
142
146
  export function FilterPanelProvider({
143
147
  children,
144
148
  hasSheet,
149
+ open: openProp,
150
+ onOpenChange,
145
151
  }: {
146
152
  children?: ReactNode;
147
153
  hasSheet: boolean;
154
+ open?: boolean;
155
+ onOpenChange?: (open: boolean) => void;
148
156
  }) {
149
- const [open, setOpen] = useState(false);
157
+ const [internalOpen, setInternalOpen] = useState(false);
150
158
  const [active, setActive] = useState(false);
159
+ const open = openProp ?? internalOpen;
160
+ const setOpen = useCallback(
161
+ (next: boolean) => {
162
+ if (openProp === undefined) setInternalOpen(next);
163
+ onOpenChange?.(next);
164
+ },
165
+ [openProp, onOpenChange],
166
+ );
151
167
  const value = useMemo(
152
168
  () => ({ open, setOpen, active, setActive, hasSheet }),
153
- [open, active, hasSheet],
169
+ [open, setOpen, active, hasSheet],
154
170
  );
155
171
  return (
156
172
  <FilterPanelCtx.Provider value={value}>{children}</FilterPanelCtx.Provider>
@@ -296,11 +312,15 @@ export function FilterSheet({
296
312
  );
297
313
 
298
314
  const sourceRow = sources && sources.length > 1 && (
299
- <div className="flex flex-wrap items-center gap-1.5 border-b border-line px-row-inset pb-2 pt-2">
315
+ <fieldset
316
+ aria-label="Accounts"
317
+ className="min-w-0 flex flex-wrap items-center gap-1.5 border-b border-line px-row-inset pb-2 pt-2"
318
+ >
300
319
  {sources.map((source) => (
301
320
  <button
302
321
  key={source.id}
303
322
  type="button"
323
+ aria-pressed={Boolean(source.active)}
304
324
  onClick={() => onSelectSource?.(source.id)}
305
325
  className={cn(
306
326
  "flex items-center gap-1 rounded-full border px-2.5 py-0.5 text-2xs transition-colors",
@@ -320,17 +340,21 @@ export function FilterSheet({
320
340
  {sourcesNote}
321
341
  </span>
322
342
  )}
323
- </div>
343
+ </fieldset>
324
344
  );
325
345
 
326
346
  const categoryRow = (
327
- <div className="flex flex-wrap items-center gap-1.5 border-b border-line px-row-inset pb-1.5 pt-1.5">
347
+ <fieldset
348
+ aria-label="Categories"
349
+ className="min-w-0 flex flex-wrap items-center gap-1.5 border-b border-line px-row-inset pb-1.5 pt-1.5"
350
+ >
328
351
  {categories.map((cat) => {
329
352
  const selected = selectedCategory === cat.id;
330
353
  return (
331
354
  <button
332
355
  key={cat.id}
333
356
  type="button"
357
+ aria-pressed={selected}
334
358
  onClick={() => onSelectCategory(cat.id)}
335
359
  className={cn(
336
360
  "shrink-0 rounded-full transition-opacity",
@@ -343,17 +367,21 @@ export function FilterSheet({
343
367
  </button>
344
368
  );
345
369
  })}
346
- </div>
370
+ </fieldset>
347
371
  );
348
372
 
349
373
  const filterRow = (
350
- <div className="flex flex-wrap items-center gap-1.5 border-b border-line px-row-inset pb-2 pt-1.5">
374
+ <fieldset
375
+ aria-label="Attributes"
376
+ className="min-w-0 flex flex-wrap items-center gap-1.5 border-b border-line px-row-inset pb-2 pt-1.5"
377
+ >
351
378
  {filters.map((f) => {
352
379
  const on = activeFilters.has(f.id);
353
380
  return (
354
381
  <button
355
382
  key={f.id}
356
383
  type="button"
384
+ aria-pressed={on}
357
385
  onClick={() => onToggleFilter(f.id)}
358
386
  className={cn(
359
387
  "shrink-0 rounded-full border px-2.5 py-0.5 text-2xs transition-colors",
@@ -376,7 +404,7 @@ export function FilterSheet({
376
404
  Clear
377
405
  </button>
378
406
  )}
379
- </div>
407
+ </fieldset>
380
408
  );
381
409
 
382
410
  return (
@@ -0,0 +1,185 @@
1
+ /**
2
+ * The cross-account brief's account pills reach the panel through the pane that
3
+ * renders the brief, not only through `BriefSections` mounted on its own. The
4
+ * caret has to be pressed for the panel to be up, so this mounts against jsdom
5
+ * rather than `renderToString`.
6
+ */
7
+ import assert from "node:assert/strict";
8
+ import { after, afterEach, before, beforeEach, describe, it } from "node:test";
9
+ import type { JSDOM } from "jsdom";
10
+ import { act, createElement } from "react";
11
+ import { createRoot, type Root } from "react-dom/client";
12
+ import type { ThreadSection } from "./app-shell-types.js";
13
+ import type { BriefFilterSurface } from "./brief-sections.js";
14
+ import { MessageListPane } from "./message-list-pane.js";
15
+
16
+ const sections: ThreadSection[] = [
17
+ {
18
+ id: "personal",
19
+ label: "Personal",
20
+ threads: [
21
+ {
22
+ id: "t1",
23
+ accountId: "acc_personal",
24
+ fromName: "Priya Nair",
25
+ fromEmail: "priya@example.com",
26
+ subject: "Design review tomorrow",
27
+ snippet: "Can we move it to 2pm?",
28
+ timeLabel: "8:15",
29
+ isRead: false,
30
+ category: "personal",
31
+ },
32
+ ],
33
+ },
34
+ ];
35
+
36
+ const sources = [
37
+ { id: "all", label: "All", active: true },
38
+ { id: "acc_personal", label: "Personal", count: 4 },
39
+ { id: "acc_work", label: "Work", count: 7 },
40
+ ];
41
+
42
+ let dom: JSDOM;
43
+ let container: HTMLElement;
44
+ let root: Root;
45
+
46
+ before(async () => {
47
+ const { JSDOM: JSDOMCtor } = await import("jsdom");
48
+ dom = new JSDOMCtor(
49
+ "<!doctype html><html><body><div id=root></div></body></html>",
50
+ { url: "http://localhost/", pretendToBeVisual: true },
51
+ );
52
+ globalThis.window = dom.window as unknown as typeof globalThis.window;
53
+ globalThis.document = dom.window.document;
54
+ globalThis.HTMLElement = dom.window.HTMLElement;
55
+ globalThis.Element = dom.window.Element;
56
+ globalThis.MouseEvent = dom.window.MouseEvent;
57
+ Object.defineProperty(globalThis, "navigator", {
58
+ value: dom.window.navigator,
59
+ configurable: true,
60
+ });
61
+ (
62
+ globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
63
+ ).IS_REACT_ACT_ENVIRONMENT = true;
64
+ });
65
+
66
+ after(() => {
67
+ dom.window.close();
68
+ });
69
+
70
+ beforeEach(() => {
71
+ container = dom.window.document.getElementById(
72
+ "root",
73
+ ) as unknown as HTMLElement;
74
+ container.innerHTML = "";
75
+ root = createRoot(container);
76
+ });
77
+
78
+ afterEach(() => {
79
+ act(() => {
80
+ root.unmount();
81
+ });
82
+ });
83
+
84
+ function mount(briefFilter?: BriefFilterSurface) {
85
+ act(() => {
86
+ root.render(
87
+ createElement(MessageListPane, {
88
+ listTitle: "Daily brief",
89
+ sections,
90
+ briefFilters: true,
91
+ isDesktop: true,
92
+ briefFilter,
93
+ }),
94
+ );
95
+ });
96
+ }
97
+
98
+ function click(element: Element) {
99
+ act(() => {
100
+ element.dispatchEvent(
101
+ new dom.window.MouseEvent("click", { bubbles: true, cancelable: true }),
102
+ );
103
+ });
104
+ }
105
+
106
+ function openPanel() {
107
+ const caret = container.querySelector('[aria-label="Expand filters"]');
108
+ assert.ok(caret, "the pane offers a way to open the filter panel");
109
+ click(caret);
110
+ }
111
+
112
+ function labels(selector: string): string[] {
113
+ return Array.from(container.querySelectorAll(selector)).map((node) =>
114
+ (node.textContent ?? "").trim(),
115
+ );
116
+ }
117
+
118
+ describe("the brief's filter panel inside the message list pane", () => {
119
+ it("carries the account pills and the muted note it is given", () => {
120
+ mount({
121
+ sources,
122
+ sourcesNote: "+1 muted",
123
+ onSelectSource: () => undefined,
124
+ });
125
+ openPanel();
126
+ const pills = labels("button");
127
+ assert.ok(
128
+ pills.includes("Personal4"),
129
+ "a pill per account, with its count",
130
+ );
131
+ assert.ok(pills.includes("Work7"));
132
+ assert.match(container.textContent ?? "", /\+1 muted/);
133
+ });
134
+
135
+ it("reports the pill the user picks", () => {
136
+ let picked: string | undefined;
137
+ mount({
138
+ sources,
139
+ onSelectSource: (id) => {
140
+ picked = id;
141
+ },
142
+ });
143
+ openPanel();
144
+ const work = Array.from(container.querySelectorAll("button")).find(
145
+ (node) => (node.textContent ?? "").trim() === "Work7",
146
+ );
147
+ assert.ok(work);
148
+ click(work);
149
+ assert.equal(picked, "acc_work");
150
+ });
151
+
152
+ it("offers no source row without pills", () => {
153
+ mount();
154
+ openPanel();
155
+ assert.doesNotMatch(container.textContent ?? "", /Work/);
156
+ });
157
+
158
+ it("draws the chips the caller holds and reports a toggle", () => {
159
+ let toggled: string | undefined;
160
+ mount({
161
+ sources,
162
+ activeFilters: new Set(["unread"]),
163
+ onToggleFilter: (id) => {
164
+ toggled = id;
165
+ },
166
+ onClearFilters: () => undefined,
167
+ });
168
+ openPanel();
169
+ const unread = Array.from(container.querySelectorAll("button")).find(
170
+ (node) => (node.textContent ?? "").trim() === "Unread",
171
+ );
172
+ assert.ok(unread);
173
+ assert.equal(
174
+ unread.getAttribute("aria-pressed"),
175
+ "true",
176
+ "the held chip reads as active",
177
+ );
178
+ const attachment = Array.from(container.querySelectorAll("button")).find(
179
+ (node) => (node.textContent ?? "").trim() === "Has attachment",
180
+ );
181
+ assert.ok(attachment);
182
+ click(attachment);
183
+ assert.equal(toggled, "attachment");
184
+ });
185
+ });
@@ -37,7 +37,6 @@ const baseProps = {
37
37
  sections,
38
38
  flatList: true,
39
39
  onSelectThread: () => undefined,
40
- onSelectBriefCategory: () => undefined,
41
40
  };
42
41
 
43
42
  describe("MessageListPane", () => {
@@ -68,7 +68,6 @@ const meta: Meta<typeof MessageListPane> = {
68
68
  listMeta: "3 conversations",
69
69
  sections,
70
70
  onSelectThread: () => undefined,
71
- onSelectBriefCategory: () => undefined,
72
71
  },
73
72
  };
74
73
  export default meta;
@@ -8,7 +8,7 @@ import type {
8
8
  MessageListSelection,
9
9
  TouchSeed,
10
10
  } from "./app-shell-types.js";
11
- import { BriefSections } from "./brief-sections.js";
11
+ import { type BriefFilterSurface, BriefSections } from "./brief-sections.js";
12
12
  import { Button } from "./button.js";
13
13
  import { KeyboardHintBar } from "./keyboard-hint-bar.js";
14
14
  import {
@@ -46,11 +46,10 @@ export function MessageListPane({
46
46
  errorMessage,
47
47
  onRetry,
48
48
  onReportError,
49
- briefCategory,
49
+ briefFilter,
50
50
  selectedThreadId,
51
51
  density = "comfortable",
52
52
  onSelectThread,
53
- onSelectBriefCategory,
54
53
  onOpenNav,
55
54
  isDesktop,
56
55
  initialTouchState,
@@ -71,11 +70,9 @@ export function MessageListPane({
71
70
  | "errorMessage"
72
71
  | "onRetry"
73
72
  | "onReportError"
74
- | "briefCategory"
75
73
  | "selectedThreadId"
76
74
  | "density"
77
75
  | "onSelectThread"
78
- | "onSelectBriefCategory"
79
76
  > & {
80
77
  /**
81
78
  * The active category filter, when the caller has one. Without it the empty
@@ -84,6 +81,14 @@ export function MessageListPane({
84
81
  * unfiltered, so a surface that filters must pass this.
85
82
  */
86
83
  listFilter?: MessageListFilter;
84
+ /**
85
+ * The brief's category scope, account pills and attribute chips, held by the
86
+ * caller. The cross-account brief is segmented from this panel, and a caller
87
+ * narrowing the same rows on a second surface hands both the one set it
88
+ * holds. Absent, the chips are the brief's own, no source row is offered and
89
+ * every category is in scope.
90
+ */
91
+ briefFilter?: BriefFilterSurface;
87
92
  /** Name of the collection, e.g. "Inbox". Passed to the empty state. */
88
93
  listScopeLabel?: string;
89
94
  /** When set, the list header shows a folders/menu button that opens the nav
@@ -250,12 +255,11 @@ export function MessageListPane({
250
255
  />
251
256
  ) : briefFilters ? (
252
257
  <BriefSections
258
+ {...(briefFilter ?? {})}
253
259
  sections={sections}
254
- briefCategory={briefCategory}
255
260
  selectedThreadId={selectedThreadId}
256
261
  Row={BriefRow}
257
262
  onSelectThread={onSelectThread}
258
- onSelectBriefCategory={onSelectBriefCategory}
259
263
  />
260
264
  ) : listBody != null ? (
261
265
  /* Consumer-provided body wins on every width — it owns the rows
package/src/index.ts CHANGED
@@ -29,6 +29,7 @@ export {
29
29
  categoryTone,
30
30
  type Density,
31
31
  INTELLIGENCE_MIN_WIDTH,
32
+ isBriefCategory,
32
33
  type MessageListSelection,
33
34
  type NarrowView,
34
35
  type NavAccount,
@@ -87,9 +88,14 @@ export {
87
88
  SECTION_ROW_CAP,
88
89
  } from "./components/brief-section.js";
89
90
  export {
91
+ type BriefCategoryControl,
92
+ type BriefFilterControl,
90
93
  type BriefFilterId,
94
+ type BriefFilterSurface,
91
95
  BriefSections,
92
96
  type BriefSectionsProps,
97
+ type BriefSourceControl,
98
+ isBriefFilterId,
93
99
  matchesBriefFilters,
94
100
  } from "./components/brief-sections.js";
95
101
  export {