@remit/ui 0.0.128 → 0.0.130

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.
Files changed (33) hide show
  1. package/package.json +2 -3
  2. package/src/components/brief-sections.keyboard.test.ts +8 -36
  3. package/src/components/compose-body.language.test.ts +8 -58
  4. package/src/components/compose-body.spellcheck.test.ts +8 -55
  5. package/src/components/event-editor.render.test.ts +2 -2
  6. package/src/components/filter-rule-editor.create.test.ts +10 -59
  7. package/src/components/folder-tree-picker.create.test.ts +18 -63
  8. package/src/components/mail-action-toolbar.tsx +17 -10
  9. package/src/components/message-list-pane.brief-filter.test.ts +4 -31
  10. package/src/components/nav-link-surface.interaction.test.ts +7 -34
  11. package/src/components/nav-sidebar.keyboard.test.ts +11 -40
  12. package/src/components/password-input.render.test.ts +8 -50
  13. package/src/components/plain-text-editor.mount.test.ts +11 -66
  14. package/src/components/reading-pane.tsx +10 -1
  15. package/src/components/rich-text-editor.mount.test.ts +9 -63
  16. package/src/components/rich-text-formatting.test.ts +27 -45
  17. package/src/components/rich-text-image-node.test.ts +60 -83
  18. package/src/components/rich-text-markdown.test.ts +3 -25
  19. package/src/components/rich-text-paste.test.ts +28 -42
  20. package/src/components/rich-text-spellcheck-menu.test.ts +28 -94
  21. package/src/components/rich-text-spellcheck.test.ts +11 -65
  22. package/src/components/swipeable-row.gesture.test.ts +8 -42
  23. package/src/components/touch-list.follows-sections.test.ts +4 -27
  24. package/src/lib/adopted-html.test.ts +3 -22
  25. package/src/lib/compose-language.test.ts +2 -7
  26. package/src/lib/roving-focus.test.ts +16 -43
  27. package/src/lib/use-list-cursor.test.ts +3 -27
  28. package/src/lib/use-list-keyboard.test.ts +6 -30
  29. package/src/lib/use-long-press.test.ts +12 -43
  30. package/src/lib/use-rendered-row-ids.test.ts +3 -24
  31. package/src/lib/use-selection.hook.test.ts +3 -26
  32. package/src/lib/use-suggest-list.test.ts +5 -29
  33. package/src/lib/use-triage-keyboard.test.ts +6 -29
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/ui",
3
- "version": "0.0.128",
3
+ "version": "0.0.130",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "src"
@@ -49,12 +49,11 @@
49
49
  "@types/react-dom": "^19"
50
50
  },
51
51
  "devDependencies": {
52
+ "@remit/test-dom": "*",
52
53
  "@storybook/react": "^9",
53
- "@types/jsdom": "^28.0.3",
54
54
  "dictionary-en": "4.0.0",
55
55
  "dictionary-en-gb": "3.0.0",
56
56
  "dictionary-nl": "2.0.0",
57
- "jsdom": "^29.1.1",
58
57
  "react": "^19",
59
58
  "react-dom": "^19",
60
59
  "typescript": "*"
@@ -2,9 +2,9 @@
2
2
  * BriefSections arrow-key traversal (#143) — mounted against jsdom rather than
3
3
  * `renderToString`, since focus and keydown need a real `document`.
4
4
  */
5
+ import "@remit/test-dom";
5
6
  import assert from "node:assert/strict";
6
- import { after, afterEach, before, beforeEach, describe, it } from "node:test";
7
- import type { JSDOM } from "jsdom";
7
+ import { afterEach, beforeEach, describe, it } from "node:test";
8
8
  import { act, createElement } from "react";
9
9
  import { createRoot, type Root } from "react-dom/client";
10
10
  import { LIST_ROW_SELECTOR } from "../lib/roving-focus.js";
@@ -61,39 +61,11 @@ const sections: ThreadSection[] = [
61
61
  },
62
62
  ];
63
63
 
64
- let dom: JSDOM;
65
64
  let container: HTMLElement;
66
65
  let root: Root;
67
66
 
68
- before(async () => {
69
- const { JSDOM: JSDOMCtor } = await import("jsdom");
70
- dom = new JSDOMCtor(
71
- "<!doctype html><html><body><div id=root></div></body></html>",
72
- { url: "http://localhost/", pretendToBeVisual: true },
73
- );
74
- globalThis.window = dom.window as unknown as typeof globalThis.window;
75
- globalThis.document = dom.window.document;
76
- globalThis.HTMLElement = dom.window.HTMLElement;
77
- globalThis.Element = dom.window.Element;
78
- globalThis.KeyboardEvent = dom.window.KeyboardEvent;
79
- globalThis.MutationObserver = dom.window.MutationObserver;
80
- Object.defineProperty(globalThis, "navigator", {
81
- value: dom.window.navigator,
82
- configurable: true,
83
- });
84
- (
85
- globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
86
- ).IS_REACT_ACT_ENVIRONMENT = true;
87
- });
88
-
89
- after(() => {
90
- dom.window.close();
91
- });
92
-
93
67
  beforeEach(() => {
94
- container = dom.window.document.getElementById(
95
- "root",
96
- ) as unknown as HTMLElement;
68
+ container = document.getElementById("root") as unknown as HTMLElement;
97
69
  container.innerHTML = "";
98
70
  root = createRoot(container);
99
71
  });
@@ -106,7 +78,7 @@ afterEach(() => {
106
78
 
107
79
  function pressKey(target: Element, key: string, shiftKey = false) {
108
80
  target.dispatchEvent(
109
- new dom.window.KeyboardEvent("keydown", { key, bubbles: true, shiftKey }),
81
+ new KeyboardEvent("keydown", { key, bubbles: true, shiftKey }),
110
82
  );
111
83
  }
112
84
 
@@ -147,7 +119,7 @@ describe("BriefSections arrow-key traversal", () => {
147
119
  act(() => items[0]?.focus());
148
120
  act(() => pressKey(items[0] as Element, "ArrowDown"));
149
121
  act(() => pressKey(items[1] as Element, "ArrowDown"));
150
- assert.equal(dom.window.document.activeElement, items[2]);
122
+ assert.equal(document.activeElement, items[2]);
151
123
 
152
124
  act(() => (items[2] as HTMLElement).click());
153
125
  assert.equal(selected, "t3");
@@ -159,10 +131,10 @@ describe("BriefSections arrow-key traversal", () => {
159
131
 
160
132
  act(() => items[2]?.focus());
161
133
  act(() => pressKey(items[2] as Element, "ArrowUp"));
162
- assert.equal(dom.window.document.activeElement, items[1]);
134
+ assert.equal(document.activeElement, items[1]);
163
135
 
164
136
  act(() => pressKey(items[1] as Element, "Home"));
165
- assert.equal(dom.window.document.activeElement, items[0]);
137
+ assert.equal(document.activeElement, items[0]);
166
138
  });
167
139
 
168
140
  it("steps over the section headers between rows", () => {
@@ -175,7 +147,7 @@ describe("BriefSections arrow-key traversal", () => {
175
147
 
176
148
  act(() => items[1]?.focus());
177
149
  act(() => pressKey(items[1] as Element, "ArrowDown"));
178
- assert.equal(dom.window.document.activeElement, items[2]);
150
+ assert.equal(document.activeElement, items[2]);
179
151
  });
180
152
  });
181
153
 
@@ -7,33 +7,22 @@
7
7
  * and what it falls back on has to be a set detection can choose inside — a set
8
8
  * of one is detection switched off, and the message then goes out tagged `en`
9
9
  * with every Dutch word underlined.
10
- *
11
- * React is imported after the jsdom globals are installed so its DOM bindings
12
- * bind to jsdom's prototypes.
13
10
  */
14
11
 
12
+ import "@remit/test-dom";
15
13
  import assert from "node:assert/strict";
16
- import { after, afterEach, before, beforeEach, describe, it } from "node:test";
17
- import type { JSDOM } from "jsdom";
18
- import type {
19
- act as reactAct,
20
- createElement as reactCreateElement,
21
- } from "react";
22
- import type { Root, createRoot as reactCreateRoot } from "react-dom/client";
14
+ import { afterEach, before, beforeEach, describe, it } from "node:test";
15
+ import { act, createElement } from "react";
16
+ import { createRoot, type Root } from "react-dom/client";
23
17
  import { defaultComposeLanguages } from "../lib/compose-language.js";
24
- import type { ComposeBody as ComposeBodyType } from "./compose-body.js";
18
+ import { ComposeBody } from "./compose-body.js";
25
19
  import type {
26
20
  SpellcheckOptions,
27
21
  SpellProvider,
28
22
  } from "./rich-text-spellcheck.js";
29
23
 
30
- let dom: JSDOM;
31
24
  let container: HTMLElement;
32
25
  let root: Root;
33
- let act: typeof reactAct;
34
- let createElement: typeof reactCreateElement;
35
- let createRoot: typeof reactCreateRoot;
36
- let ComposeBody: typeof ComposeBodyType;
37
26
 
38
27
  /** What the published image stages, per `REMIT_SPELLCHECK_LANGUAGES`. */
39
28
  const BUILT = ["en", "en-GB", "nl"];
@@ -117,35 +106,7 @@ const chip = (): HTMLElement => {
117
106
  return control;
118
107
  };
119
108
 
120
- before(async () => {
121
- const { JSDOM: JSDOMCtor } = await import("jsdom");
122
- dom = new JSDOMCtor(
123
- "<!doctype html><html><body><div id=root></div></body></html>",
124
- { url: "http://localhost/", pretendToBeVisual: true },
125
- );
126
- globalThis.window = dom.window as unknown as typeof globalThis.window;
127
- globalThis.document = dom.window.document;
128
- globalThis.HTMLElement = dom.window.HTMLElement;
129
- globalThis.Element = dom.window.Element;
130
- globalThis.Node = dom.window.Node;
131
- globalThis.Event = dom.window.Event;
132
- globalThis.MouseEvent = dom.window.MouseEvent;
133
- globalThis.DOMParser = dom.window.DOMParser;
134
- globalThis.MutationObserver = dom.window.MutationObserver;
135
- globalThis.Range = dom.window.Range;
136
- globalThis.AbortController = dom.window.AbortController;
137
- globalThis.AbortSignal = dom.window.AbortSignal;
138
- globalThis.getComputedStyle = dom.window.getComputedStyle.bind(dom.window);
139
- globalThis.requestAnimationFrame = dom.window.requestAnimationFrame.bind(
140
- dom.window,
141
- );
142
- globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(
143
- dom.window,
144
- );
145
- Object.defineProperty(globalThis, "navigator", {
146
- value: dom.window.navigator,
147
- configurable: true,
148
- });
109
+ before(() => {
149
110
  // The marks are drawn through the CSS Custom Highlight registry, which jsdom
150
111
  // has neither half of, and without it no checker is opened at all.
151
112
  Object.defineProperty(globalThis, "CSS", {
@@ -156,18 +117,11 @@ before(async () => {
156
117
  value: Marks,
157
118
  configurable: true,
158
119
  });
159
- (
160
- globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
161
- ).IS_REACT_ACT_ENVIRONMENT = true;
162
-
163
- ({ act, createElement } = await import("react"));
164
- ({ createRoot } = await import("react-dom/client"));
165
- ({ ComposeBody } = await import("./compose-body.js"));
166
120
  });
167
121
 
168
122
  beforeEach(() => {
169
- container = dom.window.document.createElement("div");
170
- dom.window.document.body.append(container);
123
+ container = document.createElement("div");
124
+ document.body.append(container);
171
125
  });
172
126
 
173
127
  afterEach(async () => {
@@ -177,10 +131,6 @@ afterEach(async () => {
177
131
  container.remove();
178
132
  });
179
133
 
180
- after(() => {
181
- dom.window.close();
182
- });
183
-
184
134
  const mount = async (
185
135
  text: string,
186
136
  languages: readonly string[],
@@ -16,15 +16,12 @@
16
16
  * way out so nothing is left underlined by a checker that is gone.
17
17
  */
18
18
 
19
+ import "@remit/test-dom";
19
20
  import assert from "node:assert/strict";
20
- import { after, afterEach, before, beforeEach, describe, it } from "node:test";
21
- import type { JSDOM } from "jsdom";
22
- import type {
23
- act as reactAct,
24
- createElement as reactCreateElement,
25
- } from "react";
26
- import type { Root, createRoot as reactCreateRoot } from "react-dom/client";
27
- import type { ComposeBody as ComposeBodyType } from "./compose-body.js";
21
+ import { afterEach, before, beforeEach, describe, it } from "node:test";
22
+ import { act, createElement } from "react";
23
+ import { createRoot, type Root } from "react-dom/client";
24
+ import { ComposeBody } from "./compose-body.js";
28
25
  import type {
29
26
  CheckRequest,
30
27
  ProviderStatus,
@@ -33,13 +30,8 @@ import type {
33
30
  SuggestRequest,
34
31
  } from "./rich-text-spellcheck.js";
35
32
 
36
- let dom: JSDOM;
37
33
  let container: HTMLElement;
38
34
  let root: Root;
39
- let act: typeof reactAct;
40
- let createElement: typeof reactCreateElement;
41
- let createRoot: typeof reactCreateRoot;
42
- let ComposeBody: typeof ComposeBodyType;
43
35
 
44
36
  /** Short enough that detection declines and the chip stays on the account default. */
45
37
  const DOCUMENT = "<p>Ths is redy.</p>";
@@ -215,35 +207,7 @@ const chooseLanguage = async (tag: string): Promise<void> => {
215
207
  await settle();
216
208
  };
217
209
 
218
- before(async () => {
219
- const { JSDOM: JSDOMCtor } = await import("jsdom");
220
- dom = new JSDOMCtor(
221
- "<!doctype html><html><body><div id=root></div></body></html>",
222
- { url: "http://localhost/", pretendToBeVisual: true },
223
- );
224
- globalThis.window = dom.window as unknown as typeof globalThis.window;
225
- globalThis.document = dom.window.document;
226
- globalThis.HTMLElement = dom.window.HTMLElement;
227
- globalThis.Element = dom.window.Element;
228
- globalThis.Node = dom.window.Node;
229
- globalThis.Event = dom.window.Event;
230
- globalThis.MouseEvent = dom.window.MouseEvent;
231
- globalThis.DOMParser = dom.window.DOMParser;
232
- globalThis.MutationObserver = dom.window.MutationObserver;
233
- globalThis.Range = dom.window.Range;
234
- globalThis.AbortController = dom.window.AbortController;
235
- globalThis.AbortSignal = dom.window.AbortSignal;
236
- globalThis.getComputedStyle = dom.window.getComputedStyle.bind(dom.window);
237
- globalThis.requestAnimationFrame = dom.window.requestAnimationFrame.bind(
238
- dom.window,
239
- );
240
- globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(
241
- dom.window,
242
- );
243
- Object.defineProperty(globalThis, "navigator", {
244
- value: dom.window.navigator,
245
- configurable: true,
246
- });
210
+ before(() => {
247
211
  // The marks are drawn through the CSS Custom Highlight registry, which jsdom
248
212
  // has neither half of. Without both, the editor draws nothing and never opens
249
213
  // a provider at all — which is the browser this suite would be testing.
@@ -255,18 +219,11 @@ before(async () => {
255
219
  value: Marks,
256
220
  configurable: true,
257
221
  });
258
- (
259
- globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
260
- ).IS_REACT_ACT_ENVIRONMENT = true;
261
-
262
- ({ act, createElement } = await import("react"));
263
- ({ createRoot } = await import("react-dom/client"));
264
- ({ ComposeBody } = await import("./compose-body.js"));
265
222
  });
266
223
 
267
224
  beforeEach(() => {
268
- container = dom.window.document.createElement("div");
269
- dom.window.document.body.append(container);
225
+ container = document.createElement("div");
226
+ document.body.append(container);
270
227
  });
271
228
 
272
229
  afterEach(async () => {
@@ -276,10 +233,6 @@ afterEach(async () => {
276
233
  container.remove();
277
234
  });
278
235
 
279
- after(() => {
280
- dom.window.close();
281
- });
282
-
283
236
  const mount = async (options: SpellcheckOptions): Promise<void> => {
284
237
  await act(async () => {
285
238
  root = createRoot(container);
@@ -1,6 +1,6 @@
1
+ import "@remit/test-dom";
1
2
  import assert from "node:assert/strict";
2
3
  import { describe, it } from "node:test";
3
- import { JSDOM } from "jsdom";
4
4
  import { createElement } from "react";
5
5
  import { renderToString } from "react-dom/server";
6
6
  import type { CalendarDescriptor, EventDraft } from "./calendar-types.js";
@@ -52,7 +52,7 @@ const render = (props: Partial<Parameters<typeof EventEditor>[0]>) =>
52
52
 
53
53
  /** The same render read as a document, so no assertion depends on attribute order. */
54
54
  const parse = (props: Partial<Parameters<typeof EventEditor>[0]>) =>
55
- JSDOM.fragment(render(props));
55
+ document.createRange().createContextualFragment(render(props));
56
56
 
57
57
  const field = (dom: DocumentFragment, label: string) =>
58
58
  dom.querySelector<HTMLInputElement>(`input[aria-label="${label}"]`);
@@ -3,20 +3,15 @@
3
3
  * folder is chosen, that a nested folder is told apart from a same-named
4
4
  * sibling, and that a folder can be made inside another one from here. Mounted
5
5
  * against jsdom for the tree interaction, the create form state and the async
6
- * resolve. React is imported after the jsdom globals are installed so the
7
- * controlled value tracker binds to jsdom's prototypes.
6
+ * resolve.
8
7
  */
8
+ import "@remit/test-dom";
9
9
  import assert from "node:assert/strict";
10
- import { after, afterEach, before, beforeEach, describe, it } from "node:test";
11
- import type { JSDOM } from "jsdom";
12
- import type {
13
- act as reactAct,
14
- createElement as reactCreateElement,
15
- useState as reactUseState,
16
- } from "react";
17
- import type { Root, createRoot as reactCreateRoot } from "react-dom/client";
10
+ import { afterEach, beforeEach, describe, it } from "node:test";
11
+ import { act, createElement, useState } from "react";
12
+ import { createRoot, type Root } from "react-dom/client";
18
13
  import type { FilterRule, PreviewCount } from "./filter-rule.js";
19
- import type { FilterRuleEditor as FilterRuleEditorType } from "./filter-rule-editor.js";
14
+ import { FilterRuleEditor } from "./filter-rule-editor.js";
20
15
  import type { FolderTreeNode } from "./folder-tree-picker.js";
21
16
 
22
17
  // `Prullenbak` labelled Trash is the account's own naming; two folders named
@@ -42,54 +37,11 @@ const rule: FilterRule = {
42
37
 
43
38
  const preview: PreviewCount = { status: "ready", count: 3 };
44
39
 
45
- let dom: JSDOM;
46
40
  let container: HTMLElement;
47
41
  let root: Root;
48
- let act: typeof reactAct;
49
- let createElement: typeof reactCreateElement;
50
- let useState: typeof reactUseState;
51
- let createRoot: typeof reactCreateRoot;
52
- let FilterRuleEditor: typeof FilterRuleEditorType;
53
-
54
- before(async () => {
55
- const { JSDOM: JSDOMCtor } = await import("jsdom");
56
- dom = new JSDOMCtor(
57
- "<!doctype html><html><body><div id=root></div></body></html>",
58
- { url: "http://localhost/", pretendToBeVisual: true },
59
- );
60
- globalThis.window = dom.window as unknown as typeof globalThis.window;
61
- globalThis.document = dom.window.document;
62
- globalThis.HTMLElement = dom.window.HTMLElement;
63
- globalThis.Element = dom.window.Element;
64
- globalThis.Event = dom.window.Event;
65
- Object.defineProperty(globalThis, "navigator", {
66
- value: dom.window.navigator,
67
- configurable: true,
68
- });
69
- (
70
- globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
71
- ).IS_REACT_ACT_ENVIRONMENT = true;
72
- Object.defineProperty(dom.window.HTMLElement.prototype, "scrollIntoView", {
73
- configurable: true,
74
- value: () => undefined,
75
- });
76
-
77
- const react = await import("react");
78
- act = react.act;
79
- createElement = react.createElement;
80
- useState = react.useState;
81
- ({ createRoot } = await import("react-dom/client"));
82
- ({ FilterRuleEditor } = await import("./filter-rule-editor.js"));
83
- });
84
-
85
- after(() => {
86
- dom.window.close();
87
- });
88
42
 
89
43
  beforeEach(() => {
90
- container = dom.window.document.getElementById(
91
- "root",
92
- ) as unknown as HTMLElement;
44
+ container = document.getElementById("root") as unknown as HTMLElement;
93
45
  container.innerHTML = "";
94
46
  root = createRoot(container);
95
47
  });
@@ -140,8 +92,7 @@ const mount = async ({
140
92
  };
141
93
 
142
94
  const click = async (element: Element | null | undefined) => {
143
- if (!(element instanceof dom.window.HTMLElement))
144
- assert.fail("control not rendered");
95
+ if (!(element instanceof HTMLElement)) assert.fail("control not rendered");
145
96
  await act(async () => {
146
97
  element.click();
147
98
  });
@@ -187,10 +138,10 @@ const typeName = async (value: string) => {
187
138
  assert.ok(input, "the folder name field is on screen");
188
139
  await act(async () => {
189
140
  Object.getOwnPropertyDescriptor(
190
- dom.window.HTMLInputElement.prototype,
141
+ HTMLInputElement.prototype,
191
142
  "value",
192
143
  )?.set?.call(input, value);
193
- input.dispatchEvent(new dom.window.Event("input", { bubbles: true }));
144
+ input.dispatchEvent(new Event("input", { bubbles: true }));
194
145
  });
195
146
  };
196
147
 
@@ -2,23 +2,14 @@
2
2
  * Opening folders, and the inline create form that sits at the end of an opened
3
3
  * folder's children: where it opens, what it says the parent is, and how it
4
4
  * behaves while the mail server is confirming the folder. Mounted against jsdom
5
- * for the anchoring, the field state and the async resolve. React is imported
6
- * after the jsdom globals are installed so the controlled value tracker binds to
7
- * jsdom's prototypes.
5
+ * for the anchoring, the field state and the async resolve.
8
6
  */
7
+ import "@remit/test-dom";
9
8
  import assert from "node:assert/strict";
10
- import { after, afterEach, before, beforeEach, describe, it } from "node:test";
11
- import type { JSDOM } from "jsdom";
12
- import type {
13
- act as reactAct,
14
- createElement as reactCreateElement,
15
- useState as reactUseState,
16
- } from "react";
17
- import type { Root, createRoot as reactCreateRoot } from "react-dom/client";
18
- import type {
19
- FolderTreeNode,
20
- FolderTreePicker as FolderTreePickerType,
21
- } from "./folder-tree-picker.js";
9
+ import { afterEach, before, beforeEach, describe, it } from "node:test";
10
+ import { act, createElement, useState } from "react";
11
+ import { createRoot, type Root } from "react-dom/client";
12
+ import { type FolderTreeNode, FolderTreePicker } from "./folder-tree-picker.js";
22
13
 
23
14
  const folders: FolderTreeNode[] = [
24
15
  { id: "inbox", label: "Inbox", path: "INBOX", isCurrent: true },
@@ -29,57 +20,23 @@ const folders: FolderTreeNode[] = [
29
20
  { id: "archive", label: "Archive", path: "Archive" },
30
21
  ];
31
22
 
32
- let dom: JSDOM;
33
23
  const scrolledIntoView: Element[] = [];
34
24
  let container: HTMLElement;
35
25
  let root: Root;
36
- let act: typeof reactAct;
37
- let createElement: typeof reactCreateElement;
38
- let useState: typeof reactUseState;
39
- let createRoot: typeof reactCreateRoot;
40
- let FolderTreePicker: typeof FolderTreePickerType;
41
-
42
- before(async () => {
43
- const { JSDOM: JSDOMCtor } = await import("jsdom");
44
- dom = new JSDOMCtor(
45
- "<!doctype html><html><body><div id=root></div></body></html>",
46
- { url: "http://localhost/", pretendToBeVisual: true },
47
- );
48
- globalThis.window = dom.window as unknown as typeof globalThis.window;
49
- globalThis.document = dom.window.document;
50
- globalThis.HTMLElement = dom.window.HTMLElement;
51
- globalThis.Element = dom.window.Element;
52
- globalThis.Event = dom.window.Event;
53
- Object.defineProperty(globalThis, "navigator", {
54
- value: dom.window.navigator,
55
- configurable: true,
56
- });
57
- (
58
- globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
59
- ).IS_REACT_ACT_ENVIRONMENT = true;
60
- Object.defineProperty(dom.window.HTMLElement.prototype, "scrollIntoView", {
26
+
27
+ before(() => {
28
+ Object.defineProperty(HTMLElement.prototype, "scrollIntoView", {
61
29
  configurable: true,
62
30
  value: function scrollIntoView(this: Element) {
63
31
  scrolledIntoView.push(this);
64
32
  },
65
33
  });
66
-
67
- const react = await import("react");
68
- act = react.act;
69
- createElement = react.createElement;
70
- useState = react.useState;
71
- ({ createRoot } = await import("react-dom/client"));
72
- ({ FolderTreePicker } = await import("./folder-tree-picker.js"));
73
- });
74
-
75
- after(() => {
76
- dom.window.close();
77
34
  });
78
35
 
79
36
  beforeEach(() => {
80
37
  scrolledIntoView.length = 0;
81
- container = dom.window.document.createElement("div");
82
- dom.window.document.body.append(container);
38
+ container = document.createElement("div");
39
+ document.body.append(container);
83
40
  root = createRoot(container);
84
41
  });
85
42
 
@@ -90,7 +47,7 @@ afterEach(async () => {
90
47
  container.remove();
91
48
  });
92
49
 
93
- type PickerProps = Partial<Parameters<typeof FolderTreePickerType>[0]>;
50
+ type PickerProps = Partial<Parameters<typeof FolderTreePicker>[0]>;
94
51
 
95
52
  const mount = async (props: PickerProps = {}) => {
96
53
  await act(async () => {
@@ -149,12 +106,12 @@ const typeName = async (value: string) => {
149
106
  const input = nameField();
150
107
  assert.ok(input, "name field not rendered");
151
108
  const setter = Object.getOwnPropertyDescriptor(
152
- dom.window.HTMLInputElement.prototype,
109
+ HTMLInputElement.prototype,
153
110
  "value",
154
111
  )?.set;
155
112
  await act(async () => {
156
113
  setter?.call(input, value);
157
- input.dispatchEvent(new dom.window.Event("input", { bubbles: true }));
114
+ input.dispatchEvent(new Event("input", { bubbles: true }));
158
115
  });
159
116
  };
160
117
 
@@ -165,21 +122,19 @@ const typeFilter = async (value: string) => {
165
122
  const input = filterField();
166
123
  assert.ok(input, "filter field not rendered");
167
124
  const setter = Object.getOwnPropertyDescriptor(
168
- dom.window.HTMLInputElement.prototype,
125
+ HTMLInputElement.prototype,
169
126
  "value",
170
127
  )?.set;
171
128
  await act(async () => {
172
129
  setter?.call(input, value);
173
- input.dispatchEvent(new dom.window.Event("input", { bubbles: true }));
130
+ input.dispatchEvent(new Event("input", { bubbles: true }));
174
131
  });
175
132
  };
176
133
 
177
134
  const press = async (target: Element | null | undefined, key: string) => {
178
135
  assert.ok(target, "control not rendered");
179
136
  await act(async () => {
180
- target.dispatchEvent(
181
- new dom.window.KeyboardEvent("keydown", { key, bubbles: true }),
182
- );
137
+ target.dispatchEvent(new KeyboardEvent("keydown", { key, bubbles: true }));
183
138
  });
184
139
  };
185
140
 
@@ -680,7 +635,7 @@ describe("a draft outlives the rows under it", () => {
680
635
 
681
636
  describe("filter and keyboard", () => {
682
637
  const focused = (): string | null =>
683
- dom.window.document.activeElement?.getAttribute("aria-label") ?? null;
638
+ document.activeElement?.getAttribute("aria-label") ?? null;
684
639
 
685
640
  it("opens the ancestors a match hides behind", async () => {
686
641
  await mount({});
@@ -24,9 +24,10 @@ export interface MailActionToolbarProps {
24
24
  /** Whether a thread is open. Drives the no-op-explain behaviour, never `disabled`. */
25
25
  hasThread: boolean;
26
26
  /**
27
- * Fired when an action button is pressed with no thread open. The host
28
- * surfaces a one-line inline explanation never a toast, never a disabled
29
- * button (`doc/rules/ux.md`: never disable a control).
27
+ * Fired when an action button is pressed and there is nothing behind it —
28
+ * no thread open, or no handler wired for that verb. The host surfaces a
29
+ * one-line inline explanation — never a toast, never a disabled button
30
+ * (`doc/rules/ux.md`: never disable a control).
30
31
  */
31
32
  onUnavailable?: (action: MailAction) => void;
32
33
  /** A one-line inline notice rendered under the toolbar (e.g. "Open a message first"). */
@@ -59,10 +60,12 @@ export interface MailActionToolbarProps {
59
60
 
60
61
  /**
61
62
  * The reading-pane action toolbar, shared between the remit-ui AppShell
62
- * reference and the live client. Buttons are always pressable: with no thread
63
- * open they no-op and call `onUnavailable(action)` so the host can explain why,
64
- * rather than greying out (the never-disable tenet a `disabled` button gives
65
- * the user no path to learn what it does or what they must do first).
63
+ * reference and the live client. Buttons are always pressable: where a press
64
+ * cannot act — no thread open, or no handler wired for that verb it calls
65
+ * `onUnavailable(action)` so the host can explain why, rather than greying out
66
+ * (the never-disable tenet a `disabled` button gives the user no path to
67
+ * learn what it does or what they must do first). Every press is answered; the
68
+ * bar has no branch that ends in silence.
66
69
  */
67
70
  export function MailActionToolbar({
68
71
  hasThread,
@@ -85,12 +88,16 @@ export function MailActionToolbar({
85
88
  children,
86
89
  className,
87
90
  }: MailActionToolbarProps) {
91
+ // A verb with no handler behind it is unavailable in exactly the way one with
92
+ // no thread is, and says so the same way. Dropping the press instead left the
93
+ // three reply verbs pressable and mute for as long as a host took to wire
94
+ // them (#803).
88
95
  const act = (action: MailAction, handler?: () => void) => () => {
89
- if (!hasThread) {
96
+ if (!hasThread || !handler) {
90
97
  onUnavailable?.(action);
91
98
  return;
92
99
  }
93
- handler?.();
100
+ handler();
94
101
  };
95
102
 
96
103
  return (
@@ -166,7 +173,7 @@ export function MailActionToolbar({
166
173
  <div className="flex-1" />
167
174
  {children}
168
175
  </header>
169
- {!hasThread && unavailableHint && (
176
+ {unavailableHint && (
170
177
  // biome-ignore lint/a11y/useSemanticElements: <p> with role="status" preserves block layout; <output> is inline
171
178
  <p
172
179
  role="status"