@remit/ui 0.0.79 → 0.0.81
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-types.ts +31 -0
- package/src/components/app-shell.tsx +6 -2
- package/src/components/brief-sections.tsx +38 -8
- package/src/components/filter-sheet.tsx +36 -8
- package/src/components/keyboard-hint-bar.render.test.ts +29 -0
- package/src/components/keyboard-hint-bar.tsx +2 -14
- package/src/components/message-list-pane.brief-filter.test.ts +185 -0
- package/src/components/message-list-pane.render.test.ts +0 -1
- package/src/components/message-list-pane.stories.tsx +48 -36
- package/src/components/message-list-pane.tsx +93 -11
- package/src/components/message-row.tsx +22 -2
- package/src/index.ts +36 -2
- package/src/lib/keymap-dispatch.test.ts +255 -0
- package/src/lib/keymap-dispatch.ts +244 -0
- package/src/lib/keymap.test.ts +68 -0
- package/src/lib/keymap.ts +241 -0
- package/src/lib/use-list-cursor.test.ts +273 -0
- package/src/lib/use-list-cursor.ts +263 -0
- package/src/lib/use-list-keyboard.test.ts +172 -0
- package/src/lib/use-list-keyboard.ts +101 -0
- package/src/lib/use-triage-keyboard.test.ts +153 -0
- package/src/lib/use-triage-keyboard.ts +119 -0
package/package.json
CHANGED
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
useRef,
|
|
7
7
|
useState,
|
|
8
8
|
} from "react";
|
|
9
|
+
import type { TriageHandlers } from "../lib/keymap.js";
|
|
9
10
|
import type { SelectionModifiers } from "../lib/use-selection.js";
|
|
10
11
|
import type {
|
|
11
12
|
IntelligenceData,
|
|
@@ -84,6 +85,25 @@ export interface MessageListSelection {
|
|
|
84
85
|
onRowSelect?: (id: string, modifiers: SelectionModifiers) => boolean;
|
|
85
86
|
}
|
|
86
87
|
|
|
88
|
+
/**
|
|
89
|
+
* The keyboard layer above the list, owned by whoever mounts it. The handler
|
|
90
|
+
* table is what the pane reads: it offers the keys that table answers, draws
|
|
91
|
+
* the cursor the ones it answers move, and keeps its own arrow-key traversal
|
|
92
|
+
* for the ones it does not. A layer that answers nothing offers nothing, so a
|
|
93
|
+
* host cannot advertise a key it has not wired.
|
|
94
|
+
*
|
|
95
|
+
* `useListKeyboard` builds it; the pane hands `ref` back the element the layer
|
|
96
|
+
* listens on.
|
|
97
|
+
*/
|
|
98
|
+
export interface MessageListKeyboard {
|
|
99
|
+
/** The row the cursor sits on. */
|
|
100
|
+
focusedId: string | undefined;
|
|
101
|
+
/** What the layer answers, and therefore what the footer offers. */
|
|
102
|
+
handlers: TriageHandlers;
|
|
103
|
+
/** Takes the pane element the layer binds its keys to. */
|
|
104
|
+
ref: (element: HTMLElement | null) => void;
|
|
105
|
+
}
|
|
106
|
+
|
|
87
107
|
/**
|
|
88
108
|
* Measures an element's OWN width via ResizeObserver — a container query, not a
|
|
89
109
|
* viewport one. The shell reflows by the space it actually occupies (so it works
|
|
@@ -213,6 +233,15 @@ export const briefCategories: ReadonlyArray<{
|
|
|
213
233
|
{ id: "social", label: "Social" },
|
|
214
234
|
];
|
|
215
235
|
|
|
236
|
+
/**
|
|
237
|
+
* Whether an id names one of the brief's category scopes. A host holding one
|
|
238
|
+
* category across views whose sheets speak plain strings narrows it with this
|
|
239
|
+
* rather than asserting it.
|
|
240
|
+
*/
|
|
241
|
+
export function isBriefCategory(id: string): id is BriefCategoryFilter {
|
|
242
|
+
return briefCategories.some((c) => c.id === id);
|
|
243
|
+
}
|
|
244
|
+
|
|
216
245
|
export interface ThreadRowLabel {
|
|
217
246
|
labelId: string;
|
|
218
247
|
name: string;
|
|
@@ -354,6 +383,8 @@ export interface AppShellProps {
|
|
|
354
383
|
selectionBar?: ReactNode;
|
|
355
384
|
/** The list's selection, forwarded to `MessageListPane`. */
|
|
356
385
|
selection?: MessageListSelection;
|
|
386
|
+
/** The list's keyboard layer, forwarded to `MessageListPane`. */
|
|
387
|
+
keyboard?: MessageListKeyboard;
|
|
357
388
|
intelligence?: IntelligenceData;
|
|
358
389
|
/** Pane 4 visible. Defaults to true when intelligence is present. */
|
|
359
390
|
intelligenceOpen?: boolean;
|
|
@@ -71,6 +71,7 @@ export function AppShell({
|
|
|
71
71
|
initialNarrowView = "list",
|
|
72
72
|
initialTouchState,
|
|
73
73
|
selection,
|
|
74
|
+
keyboard,
|
|
74
75
|
selectionBar,
|
|
75
76
|
intelligence,
|
|
76
77
|
intelligenceOpen = true,
|
|
@@ -129,6 +130,7 @@ export function AppShell({
|
|
|
129
130
|
onSelectBriefCategory={selectCategory}
|
|
130
131
|
initialTouchState={initialTouchState}
|
|
131
132
|
selection={selection}
|
|
133
|
+
keyboard={keyboard}
|
|
132
134
|
selectionBar={selectionBar}
|
|
133
135
|
/>
|
|
134
136
|
);
|
|
@@ -190,6 +192,7 @@ function AppShellList({
|
|
|
190
192
|
onSelectBriefCategory,
|
|
191
193
|
initialTouchState,
|
|
192
194
|
selection,
|
|
195
|
+
keyboard,
|
|
193
196
|
selectionBar,
|
|
194
197
|
}: Pick<
|
|
195
198
|
AppShellProps,
|
|
@@ -209,6 +212,7 @@ function AppShellList({
|
|
|
209
212
|
| "onSelectThread"
|
|
210
213
|
| "initialTouchState"
|
|
211
214
|
| "selection"
|
|
215
|
+
| "keyboard"
|
|
212
216
|
| "selectionBar"
|
|
213
217
|
> & {
|
|
214
218
|
narrowView: NarrowView;
|
|
@@ -244,15 +248,15 @@ function AppShellList({
|
|
|
244
248
|
searchQuery={searchQuery}
|
|
245
249
|
onRetry={onRetry}
|
|
246
250
|
onReportError={onReportError}
|
|
247
|
-
|
|
251
|
+
briefFilter={{ briefCategory, onSelectBriefCategory }}
|
|
248
252
|
selectedThreadId={selectedThreadId}
|
|
249
253
|
density={density}
|
|
250
254
|
onSelectThread={onSelectThread}
|
|
251
|
-
onSelectBriefCategory={onSelectBriefCategory}
|
|
252
255
|
onOpenNav={showNavPane ? undefined : layout?.openNav}
|
|
253
256
|
isDesktop={showReadingPane}
|
|
254
257
|
initialTouchState={initialTouchState}
|
|
255
258
|
selection={selection}
|
|
259
|
+
keyboard={keyboard}
|
|
256
260
|
selectionBar={selectionBar}
|
|
257
261
|
/>
|
|
258
262
|
);
|
|
@@ -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
|
-
|
|
93
|
-
|
|
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 [
|
|
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
|
-
<
|
|
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
|
-
</
|
|
343
|
+
</fieldset>
|
|
324
344
|
);
|
|
325
345
|
|
|
326
346
|
const categoryRow = (
|
|
327
|
-
<
|
|
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
|
-
</
|
|
370
|
+
</fieldset>
|
|
347
371
|
);
|
|
348
372
|
|
|
349
373
|
const filterRow = (
|
|
350
|
-
<
|
|
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
|
-
</
|
|
407
|
+
</fieldset>
|
|
380
408
|
);
|
|
381
409
|
|
|
382
410
|
return (
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import { createElement } from "react";
|
|
4
|
+
import { renderToString } from "react-dom/server";
|
|
5
|
+
import { keyboardHintsFor } from "../lib/keymap.js";
|
|
6
|
+
import { KeyboardHintBar } from "./keyboard-hint-bar.js";
|
|
7
|
+
|
|
8
|
+
describe("KeyboardHintBar", () => {
|
|
9
|
+
it("offers the app's own set by default", () => {
|
|
10
|
+
const html = renderToString(createElement(KeyboardHintBar));
|
|
11
|
+
assert.match(html, /<span>navigate<\/span>/);
|
|
12
|
+
assert.match(html, /<span>mute<\/span>/);
|
|
13
|
+
assert.match(html, /<span>all shortcuts<\/span>/);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("offers only what the host's handlers answer", () => {
|
|
17
|
+
const hints = keyboardHintsFor({ focusNext: () => undefined });
|
|
18
|
+
const html = renderToString(createElement(KeyboardHintBar, { hints }));
|
|
19
|
+
assert.match(html, /<span>navigate<\/span>/);
|
|
20
|
+
assert.doesNotMatch(html, /<span>mute<\/span>/);
|
|
21
|
+
assert.doesNotMatch(html, /<span>all shortcuts<\/span>/);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("renders nothing for a host that serves none of them", () => {
|
|
25
|
+
const hints = keyboardHintsFor({ toggleStar: () => undefined });
|
|
26
|
+
assert.deepEqual(hints, []);
|
|
27
|
+
assert.equal(renderToString(createElement(KeyboardHintBar, { hints })), "");
|
|
28
|
+
});
|
|
29
|
+
});
|
|
@@ -1,27 +1,14 @@
|
|
|
1
1
|
import type { ReactNode } from "react";
|
|
2
2
|
import { cn } from "../lib/cn.js";
|
|
3
|
+
import { defaultKeyboardHints, type KeyboardHint } from "../lib/keymap.js";
|
|
3
4
|
import { Kbd } from "./kbd.js";
|
|
4
5
|
|
|
5
|
-
export interface KeyboardHint {
|
|
6
|
-
/** One or more keys shown as `Kbd` chips. */
|
|
7
|
-
keys: string[];
|
|
8
|
-
/** What the keys do. */
|
|
9
|
-
label: string;
|
|
10
|
-
}
|
|
11
|
-
|
|
12
6
|
export interface KeyboardHintBarProps {
|
|
13
7
|
/** Override the default hint set. */
|
|
14
8
|
hints?: KeyboardHint[];
|
|
15
9
|
className?: string;
|
|
16
10
|
}
|
|
17
11
|
|
|
18
|
-
/** The persistent message-list footer hint set (desktop). */
|
|
19
|
-
export const defaultKeyboardHints: KeyboardHint[] = [
|
|
20
|
-
{ keys: ["j", "k"], label: "navigate" },
|
|
21
|
-
{ keys: ["m"], label: "mute" },
|
|
22
|
-
{ keys: ["?"], label: "all shortcuts" },
|
|
23
|
-
];
|
|
24
|
-
|
|
25
12
|
/**
|
|
26
13
|
* The keyboard-shortcut hint footer under the message list. Keyboard-first
|
|
27
14
|
* discoverability — the shortcuts aren't hidden behind the `?` modal. The host
|
|
@@ -32,6 +19,7 @@ export function KeyboardHintBar({
|
|
|
32
19
|
hints = defaultKeyboardHints,
|
|
33
20
|
className,
|
|
34
21
|
}: KeyboardHintBarProps) {
|
|
22
|
+
if (hints.length === 0) return null;
|
|
35
23
|
return (
|
|
36
24
|
<footer
|
|
37
25
|
className={cn(
|
|
@@ -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
|
+
});
|