@remit/ui 0.0.8 → 0.0.10

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.8",
3
+ "version": "0.0.10",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "src"
@@ -269,9 +269,7 @@ function AppShellReading({
269
269
  thread={thread}
270
270
  intelligenceOpen={showIntelligence}
271
271
  onToggleIntelligence={onToggleIntelligence}
272
- showIntelligenceToggle={
273
- isWide && Boolean(intelligence) && Boolean(thread)
274
- }
272
+ canToggleIntelligence={isWide && Boolean(intelligence) && Boolean(thread)}
275
273
  />
276
274
  );
277
275
  }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Render invariants for the (i) intelligence toggle (#52): the control holds
3
+ * its slot in the toolbar on every view and in every selection state, and
4
+ * reports "cannot act" by being disabled rather than by disappearing.
5
+ */
6
+
7
+ import assert from "node:assert/strict";
8
+ import { describe, it } from "node:test";
9
+ import { createElement } from "react";
10
+ import { renderToString } from "react-dom/server";
11
+ import { IntelligenceToggle } from "./intelligence-toggle.js";
12
+
13
+ const render = (props: Parameters<typeof IntelligenceToggle>[0]): string =>
14
+ renderToString(createElement(IntelligenceToggle, props));
15
+
16
+ describe("IntelligenceToggle", () => {
17
+ it("renders the button when it cannot act", () => {
18
+ const html = render({ enabled: false });
19
+ assert.match(html, /aria-label="Show intelligence sidebar"/);
20
+ });
21
+
22
+ it("disables rather than hides the button when it cannot act", () => {
23
+ const html = render({ enabled: false });
24
+ assert.match(html, /\sdisabled(=""|\s|>)/);
25
+ });
26
+
27
+ it("is pressable when a thread is open and a rail exists", () => {
28
+ const html = render({ enabled: true });
29
+ assert.equal(/\sdisabled(=""|\s|>)/.test(html), false);
30
+ });
31
+
32
+ it("stays rendered while the rail is open so it can close it again", () => {
33
+ const html = render({ enabled: true, open: true });
34
+ assert.match(html, /aria-label="Hide intelligence sidebar"/);
35
+ assert.match(html, /aria-pressed="true"/);
36
+ });
37
+ });
@@ -0,0 +1,53 @@
1
+ import { Info } from "lucide-react";
2
+ import { cn } from "../lib/cn.js";
3
+ import { Button } from "./button.js";
4
+
5
+ export interface IntelligenceToggleProps {
6
+ /** Whether the intelligence rail is currently open. */
7
+ open?: boolean;
8
+ /**
9
+ * Whether the toggle can act right now — a rail exists for this view, the
10
+ * width allows it, and a thread is open. When false the button still renders,
11
+ * greyed out (#52).
12
+ */
13
+ enabled?: boolean;
14
+ onToggle?: () => void;
15
+ className?: string;
16
+ }
17
+
18
+ /**
19
+ * The (i) intelligence-rail toggle in the reading-pane toolbar.
20
+ *
21
+ * The toolbar's control set is fixed: the toggle occupies the same slot on
22
+ * every view and in every selection state, and reports that it cannot act by
23
+ * being disabled rather than by vanishing (#52). A control that appears and
24
+ * disappears moves its neighbours and leaves the user with nothing to learn
25
+ * from; a greyed-out one stays where they last saw it.
26
+ *
27
+ * The title names the control rather than the reason it is off. There are three
28
+ * reasons (nothing selected, a window too narrow for the rail, a view with no
29
+ * rail), a disabled `Button` is `pointer-events-none` so no tooltip surfaces
30
+ * anyway, and a reason string that covers one case is wrong in the other two.
31
+ */
32
+ export function IntelligenceToggle({
33
+ open = false,
34
+ enabled = true,
35
+ onToggle,
36
+ className,
37
+ }: IntelligenceToggleProps) {
38
+ return (
39
+ <Button
40
+ variant="ghost"
41
+ size="sm"
42
+ icon={<Info className="size-4" />}
43
+ title="Intelligence"
44
+ aria-label={
45
+ open ? "Hide intelligence sidebar" : "Show intelligence sidebar"
46
+ }
47
+ aria-pressed={open}
48
+ disabled={!enabled}
49
+ onClick={onToggle}
50
+ className={cn(open && "bg-accent-2-soft text-accent-2", className)}
51
+ />
52
+ );
53
+ }
@@ -0,0 +1,43 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import { isForwardableKey } from "./isolated-email-frame.js";
4
+
5
+ describe("isForwardableKey", () => {
6
+ it("forwards the keys that move around the app", () => {
7
+ for (const key of [
8
+ "j",
9
+ "k",
10
+ "g",
11
+ "x",
12
+ "r",
13
+ "#",
14
+ "Enter",
15
+ "Escape",
16
+ "ArrowUp",
17
+ "ArrowDown",
18
+ "Home",
19
+ "End",
20
+ ]) {
21
+ assert.equal(isForwardableKey(key), true, key);
22
+ }
23
+ });
24
+
25
+ it("keeps destructive keys inside the message body", () => {
26
+ // The replayed event carries no focused control, so the app routes what it
27
+ // recognises straight at the message list. Backspace while reading must not
28
+ // become a delete of the row behind the reader.
29
+ assert.equal(isForwardableKey("Backspace"), false);
30
+ assert.equal(isForwardableKey("Delete"), false);
31
+ });
32
+
33
+ it("keeps Space inside the message body", () => {
34
+ // Space belongs to reading the email, not to selecting rows behind it.
35
+ assert.equal(isForwardableKey(" "), false);
36
+ });
37
+
38
+ it("does not forward unknown named keys", () => {
39
+ assert.equal(isForwardableKey("F5"), false);
40
+ assert.equal(isForwardableKey("Tab"), false);
41
+ assert.equal(isForwardableKey("PageDown"), false);
42
+ });
43
+ });
@@ -104,6 +104,61 @@ const useMatchMedia = (query: string): boolean => {
104
104
  return matches;
105
105
  };
106
106
 
107
+ /** Named (non-character) keys worth replaying: moving around and closing. */
108
+ const FORWARDED_NAMED_KEYS = new Set([
109
+ "Enter",
110
+ "Escape",
111
+ "ArrowUp",
112
+ "ArrowDown",
113
+ "ArrowLeft",
114
+ "ArrowRight",
115
+ "Home",
116
+ "End",
117
+ ]);
118
+
119
+ /**
120
+ * Which keystrokes leave the frame. An allowlist rather than a denylist: the
121
+ * replayed event's target is the host window, so the app's keyboard layer sees
122
+ * no focused control and routes everything it recognises to the message list.
123
+ * Forwarding indiscriminately would therefore let Backspace pressed while
124
+ * reading delete the message under the cursor, and Space select it.
125
+ *
126
+ * Single characters (letters, digits, punctuation) carry the app's navigation
127
+ * and verb bindings and are safe to replay. Space is excluded: it belongs to
128
+ * reading the email, not to selecting rows behind it.
129
+ */
130
+ export const isForwardableKey = (key: string): boolean => {
131
+ if (key === " ") return false;
132
+ if (key.length === 1) return true;
133
+ return FORWARDED_NAMED_KEYS.has(key);
134
+ };
135
+
136
+ /**
137
+ * Keydown events raised inside an iframe never reach the embedding window, so
138
+ * once the reader clicks into the message body every app shortcut goes dead —
139
+ * j/k, arrows, Esc, the lot (#43). Replay the allowlisted keystrokes on the host
140
+ * window so the app's one keyboard layer keeps hearing them. The replay is a
141
+ * copy: the original event is untouched, so selecting and scrolling inside the
142
+ * email still behave normally. Modifier combos stay with the frame and the
143
+ * browser.
144
+ */
145
+ const forwardKeyDown = (event: KeyboardEvent) => {
146
+ if (event.metaKey || event.ctrlKey || event.altKey) return;
147
+ if (!isForwardableKey(event.key)) return;
148
+ const doc = (event.currentTarget ?? event.target) as Document | null;
149
+ const host = doc?.defaultView?.parent;
150
+ if (!host || host === doc?.defaultView) return;
151
+ host.dispatchEvent(
152
+ new KeyboardEvent("keydown", {
153
+ key: event.key,
154
+ code: event.code,
155
+ shiftKey: event.shiftKey,
156
+ bubbles: true,
157
+ cancelable: true,
158
+ }),
159
+ );
160
+ };
161
+
107
162
  /**
108
163
  * Render untrusted (sanitized) email HTML in a sandboxed iframe that fits the
109
164
  * viewport width on mobile and isolates the email's CSS from the app chrome.
@@ -175,6 +230,7 @@ export const IsolatedEmailFrame = ({
175
230
  };
176
231
 
177
232
  let observer: ResizeObserver | undefined;
233
+ let keyDoc: Document | undefined;
178
234
  const handleLoad = () => {
179
235
  measure();
180
236
  const doc = iframe.contentDocument;
@@ -182,11 +238,14 @@ export const IsolatedEmailFrame = ({
182
238
  observer = new ResizeObserver(measure);
183
239
  observer.observe(doc.body);
184
240
  if (doc.documentElement) observer.observe(doc.documentElement);
241
+ doc.addEventListener("keydown", forwardKeyDown);
242
+ keyDoc = doc;
185
243
  };
186
244
 
187
245
  iframe.addEventListener("load", handleLoad);
188
246
  return () => {
189
247
  iframe.removeEventListener("load", handleLoad);
248
+ keyDoc?.removeEventListener("keydown", forwardKeyDown);
190
249
  observer?.disconnect();
191
250
  };
192
251
  }, []);
@@ -88,7 +88,7 @@ export const Newsletter: Story = { args: { thread: newsletterThread } };
88
88
  export const Empty: Story = { args: { thread: undefined } };
89
89
 
90
90
  export const WithIntelligenceToggle: Story = {
91
- args: { thread, showIntelligenceToggle: true, intelligenceOpen: false },
91
+ args: { thread, canToggleIntelligence: true, intelligenceOpen: false },
92
92
  };
93
93
 
94
94
  /* ------------------------------------------------------------------ */
@@ -1,7 +1,6 @@
1
1
  import {
2
2
  ChevronDown,
3
3
  ChevronRight,
4
- Info,
5
4
  Search,
6
5
  ShieldAlert,
7
6
  SquarePen,
@@ -13,6 +12,7 @@ import type { ThreadData, ThreadMessageData } from "./app-shell-types.js";
13
12
  import { Avatar } from "./avatar.js";
14
13
  import { Button } from "./button.js";
15
14
  import { Input } from "./input.js";
15
+ import { IntelligenceToggle } from "./intelligence-toggle.js";
16
16
  import { MailActionToolbar } from "./mail-action-toolbar.js";
17
17
  import { MessageBodyView } from "./message-body-view.js";
18
18
  import { ReadingPaneEmpty } from "./reading-pane-empty.js";
@@ -248,19 +248,21 @@ export function ExpandedMessage({
248
248
  * Message action toolbar on the pane-header datum: the reading pane's
249
249
  * verbs (reply/reply-all/forward, delete/move/flag) plus search and
250
250
  * compose, Apple Mail-style above the message area. Built on the shared
251
- * `MailActionToolbar` so the buttons stay pressable with no thread open and
252
- * explain inline rather than greying out (the never-disable tenet).
251
+ * `MailActionToolbar` so the mail verbs stay pressable with no
252
+ * thread open and explain inline rather than greying out (the never-disable
253
+ * tenet). The intelligence toggle is the exception: it holds its slot and greys
254
+ * out when there is no rail to open (#52).
253
255
  */
254
256
  function MessageToolbar({
255
257
  hasThread,
256
258
  intelligenceOpen,
257
259
  onToggleIntelligence,
258
- showIntelligenceToggle,
260
+ canToggleIntelligence = false,
259
261
  }: {
260
262
  hasThread: boolean;
261
263
  intelligenceOpen?: boolean;
262
264
  onToggleIntelligence?: () => void;
263
- showIntelligenceToggle?: boolean;
265
+ canToggleIntelligence?: boolean;
264
266
  }) {
265
267
  const [hint, setHint] = useState<string | null>(null);
266
268
  return (
@@ -284,15 +286,11 @@ function MessageToolbar({
284
286
  title="Compose (⌘N)"
285
287
  aria-label="Compose"
286
288
  />
287
- {showIntelligenceToggle && !intelligenceOpen && (
288
- <Button
289
- variant="ghost"
290
- size="sm"
291
- icon={<Info className="size-4" />}
292
- onClick={onToggleIntelligence}
293
- aria-label="Show intelligence sidebar"
294
- />
295
- )}
289
+ <IntelligenceToggle
290
+ open={intelligenceOpen}
291
+ enabled={canToggleIntelligence}
292
+ onToggle={onToggleIntelligence}
293
+ />
296
294
  </MailActionToolbar>
297
295
  );
298
296
  }
@@ -301,12 +299,12 @@ export function ReadingPane({
301
299
  thread,
302
300
  intelligenceOpen,
303
301
  onToggleIntelligence,
304
- showIntelligenceToggle,
302
+ canToggleIntelligence,
305
303
  }: {
306
304
  thread?: ThreadData;
307
305
  intelligenceOpen?: boolean;
308
306
  onToggleIntelligence?: () => void;
309
- showIntelligenceToggle?: boolean;
307
+ canToggleIntelligence?: boolean;
310
308
  }) {
311
309
  return (
312
310
  <article className="flex h-full w-full min-w-0 flex-col bg-canvas">
@@ -314,7 +312,7 @@ export function ReadingPane({
314
312
  hasThread={Boolean(thread)}
315
313
  intelligenceOpen={intelligenceOpen}
316
314
  onToggleIntelligence={onToggleIntelligence}
317
- showIntelligenceToggle={showIntelligenceToggle}
315
+ canToggleIntelligence={canToggleIntelligence}
318
316
  />
319
317
 
320
318
  {!thread ? (
package/src/index.ts CHANGED
@@ -144,6 +144,10 @@ export {
144
144
  type SimilarMessageLinkProps,
145
145
  type SimilarState,
146
146
  } from "./components/intelligence-panel.js";
147
+ export {
148
+ IntelligenceToggle,
149
+ type IntelligenceToggleProps,
150
+ } from "./components/intelligence-toggle.js";
147
151
  export {
148
152
  IsolatedEmailFrame,
149
153
  type IsolatedEmailFrameProps,