@remit/ui 0.0.114 → 0.0.116

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.114",
3
+ "version": "0.0.116",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "src"
@@ -13,7 +13,7 @@
13
13
  "scripts": {
14
14
  "test": "npm run test:typecheck && npm run test:run",
15
15
  "test:typecheck": "tsgo --noEmit",
16
- "test:run": "node --import tsx --experimental-test-coverage --test-coverage-include='src/**' --test-coverage-exclude='src/**/*.test.ts' --test-coverage-exclude='src/**/*.test.tsx' --test-coverage-lines=90 --test 'src/**/*.test.ts'"
16
+ "test:run": "node $NODE_TEST_FLAGS --import tsx --experimental-test-coverage --test-coverage-include='src/**' --test-coverage-exclude='src/**/*.test.ts' --test-coverage-exclude='src/**/*.test.tsx' --test-coverage-lines=90 --test 'src/**/*.test.ts'"
17
17
  },
18
18
  "dependencies": {
19
19
  "@fontsource-variable/geist": "^5",
@@ -44,12 +44,12 @@
44
44
  "peerDependencies": {
45
45
  "react": "^19",
46
46
  "react-dom": "^19",
47
- "@types/react": "^19"
47
+ "@types/react": "^19",
48
+ "@types/react-dom": "^19"
48
49
  },
49
50
  "devDependencies": {
50
51
  "@storybook/react": "^9",
51
52
  "@types/jsdom": "^28.0.3",
52
- "@types/react-dom": "^19",
53
53
  "jsdom": "^29.1.1",
54
54
  "react": "^19",
55
55
  "react-dom": "^19",
@@ -1,4 +1,5 @@
1
1
  import { useEffect, useMemo, useRef, useState } from "react";
2
+ import { useMatchMedia } from "../lib/use-match-media.js";
2
3
  import { buildEmailSrcDoc, type EmailFrameVariant } from "./email-frame-css.js";
3
4
 
4
5
  export interface IsolatedEmailFrameProps {
@@ -86,24 +87,6 @@ export const computeFitScale = (
86
87
  return Math.max(MIN_SCALE, containerWidth / contentWidth);
87
88
  };
88
89
 
89
- const useMatchMedia = (query: string): boolean => {
90
- const [matches, setMatches] = useState(() => {
91
- if (typeof window === "undefined" || !window.matchMedia) return false;
92
- return window.matchMedia(query).matches;
93
- });
94
-
95
- useEffect(() => {
96
- if (typeof window === "undefined" || !window.matchMedia) return;
97
- const mql = window.matchMedia(query);
98
- setMatches(mql.matches);
99
- const handler = (event: MediaQueryListEvent) => setMatches(event.matches);
100
- mql.addEventListener("change", handler);
101
- return () => mql.removeEventListener("change", handler);
102
- }, [query]);
103
-
104
- return matches;
105
- };
106
-
107
90
  /** Named (non-character) keys worth replaying: moving around and closing. */
108
91
  const FORWARDED_NAMED_KEYS = new Set([
109
92
  "Enter",
@@ -1,8 +1,139 @@
1
1
  import { EllipsisVertical } from "lucide-react";
2
- import { type ReactNode, useEffect, useRef, useState } from "react";
2
+ import type { CSSProperties, Ref, RefObject } from "react";
3
+ import {
4
+ type ReactNode,
5
+ useEffect,
6
+ useLayoutEffect,
7
+ useRef,
8
+ useState,
9
+ } from "react";
10
+ import { createPortal } from "react-dom";
3
11
  import { cn } from "../lib/cn.js";
4
12
  import { Button } from "./button.js";
5
13
 
14
+ /** Viewport-relative bounding box of whatever a menu opens against. */
15
+ export interface PopoverMenuAnchor {
16
+ readonly left: number;
17
+ readonly right: number;
18
+ readonly top: number;
19
+ readonly bottom: number;
20
+ }
21
+
22
+ const ANCHOR_GAP_PX = 4;
23
+ const VIEWPORT_MARGIN_PX = 8;
24
+
25
+ /**
26
+ * Where the panel lands: below the anchor and aligned to whichever edge
27
+ * `align` names, pulled back onto the screen on every side it would
28
+ * otherwise cross. A panel too tall for the space below flips above the
29
+ * anchor instead of running past the bottom edge.
30
+ */
31
+ function clampToViewport(
32
+ anchor: PopoverMenuAnchor,
33
+ panel: { width: number; height: number },
34
+ align: "start" | "end",
35
+ ): { left: number; top: number } {
36
+ const maxLeft = Math.max(
37
+ VIEWPORT_MARGIN_PX,
38
+ window.innerWidth - panel.width - VIEWPORT_MARGIN_PX,
39
+ );
40
+ const preferredLeft =
41
+ align === "end" ? anchor.right - panel.width : anchor.left;
42
+ const left = Math.min(Math.max(preferredLeft, VIEWPORT_MARGIN_PX), maxLeft);
43
+
44
+ const below = anchor.bottom + ANCHOR_GAP_PX;
45
+ const above = anchor.top - ANCHOR_GAP_PX - panel.height;
46
+ const fitsBelow =
47
+ below + panel.height <= window.innerHeight - VIEWPORT_MARGIN_PX;
48
+ const top = fitsBelow || above < VIEWPORT_MARGIN_PX ? below : above;
49
+
50
+ return { left, top };
51
+ }
52
+
53
+ /**
54
+ * Measures the anchor and the panel's own size, then keeps the panel's fixed
55
+ * position clamped to the viewport for as long as it is open — reset on every
56
+ * resize and on scroll anywhere in the ancestor chain, since a fixed position
57
+ * does not follow a scrolled anchor on its own.
58
+ */
59
+ function useAnchoredPlacement(
60
+ panelRef: RefObject<HTMLElement | null>,
61
+ open: boolean,
62
+ align: "start" | "end",
63
+ getAnchor: () => PopoverMenuAnchor | null,
64
+ ): CSSProperties | null {
65
+ const [style, setStyle] = useState<CSSProperties | null>(null);
66
+ const getAnchorRef = useRef(getAnchor);
67
+ getAnchorRef.current = getAnchor;
68
+
69
+ useLayoutEffect(() => {
70
+ if (!open) {
71
+ setStyle(null);
72
+ return;
73
+ }
74
+ const place = () => {
75
+ const panel = panelRef.current;
76
+ const anchor = getAnchorRef.current();
77
+ if (!panel || !anchor) return;
78
+ // `.width`/`.height` over the rect's own edges: a `getBoundingClientRect`
79
+ // stand-in in a test carries the edges without the derived pair.
80
+ const rect = panel.getBoundingClientRect();
81
+ const size = {
82
+ width: rect.right - rect.left,
83
+ height: rect.bottom - rect.top,
84
+ };
85
+ const placement = clampToViewport(anchor, size, align);
86
+ setStyle((previous) =>
87
+ previous?.left === placement.left && previous.top === placement.top
88
+ ? previous
89
+ : { position: "fixed", left: placement.left, top: placement.top },
90
+ );
91
+ };
92
+ place();
93
+ window.addEventListener("resize", place);
94
+ window.addEventListener("scroll", place, true);
95
+ return () => {
96
+ window.removeEventListener("resize", place);
97
+ window.removeEventListener("scroll", place, true);
98
+ };
99
+ }, [open, align, panelRef]);
100
+
101
+ return style;
102
+ }
103
+
104
+ export interface PopoverMenuPortalProps {
105
+ open: boolean;
106
+ align?: "start" | "end";
107
+ getAnchor: () => PopoverMenuAnchor | null;
108
+ panelRef: RefObject<HTMLElement | null>;
109
+ children: ReactNode;
110
+ }
111
+
112
+ /**
113
+ * Carries a menu panel out of the DOM subtree it opened from and into the
114
+ * document body, positioned at a fixed, viewport-clamped point. A panel left
115
+ * in place is clipped by the first scrolling ancestor between it and the
116
+ * page — the compose body, a card, anything with its own `overflow` — no
117
+ * matter how high its `z-index` climbs; escaping that ancestor takes leaving
118
+ * its DOM subtree, which only a portal does.
119
+ */
120
+ export function PopoverMenuPortal({
121
+ open,
122
+ align = "start",
123
+ getAnchor,
124
+ panelRef,
125
+ children,
126
+ }: PopoverMenuPortalProps) {
127
+ const style = useAnchoredPlacement(panelRef, open, align, getAnchor);
128
+ if (!open) return null;
129
+ return createPortal(
130
+ <div style={style ?? { position: "fixed", visibility: "hidden" }}>
131
+ {children}
132
+ </div>,
133
+ document.body,
134
+ );
135
+ }
136
+
6
137
  export interface PopoverMenuItem {
7
138
  /** Stable key, also the accessible text of the row. */
8
139
  key: string;
@@ -11,6 +142,83 @@ export interface PopoverMenuItem {
11
142
  onSelect: () => void;
12
143
  }
13
144
 
145
+ export interface PopoverMenuPanelProps {
146
+ label?: string;
147
+ children: ReactNode;
148
+ className?: string;
149
+ ref?: Ref<HTMLDivElement>;
150
+ onKeyDown?: (event: React.KeyboardEvent<HTMLDivElement>) => void;
151
+ "data-testid"?: string;
152
+ }
153
+
154
+ /**
155
+ * The menu itself, without a trigger: the surface, the scrolling and the
156
+ * rounding every dropdown in the kit shares. A menu that hangs off a button
157
+ * gets it through {@link PopoverMenu}; one anchored to something else — the
158
+ * misspelt word under the pointer — positions this and keeps the same chrome.
159
+ */
160
+ export function PopoverMenuPanel({
161
+ label,
162
+ children,
163
+ className,
164
+ ref,
165
+ onKeyDown,
166
+ "data-testid": testId,
167
+ }: PopoverMenuPanelProps) {
168
+ return (
169
+ <div
170
+ ref={ref}
171
+ role="menu"
172
+ aria-label={label}
173
+ tabIndex={-1}
174
+ onKeyDown={onKeyDown}
175
+ data-testid={testId}
176
+ className={cn(
177
+ "z-50 flex max-h-[60dvh] min-w-44 flex-col overflow-y-auto overscroll-contain rounded-md border border-line bg-surface py-1 shadow-lg outline-none",
178
+ className,
179
+ )}
180
+ >
181
+ {children}
182
+ </div>
183
+ );
184
+ }
185
+
186
+ export interface PopoverMenuRowProps {
187
+ label: string;
188
+ icon?: ReactNode;
189
+ onSelect: () => void;
190
+ className?: string;
191
+ lang?: string;
192
+ "data-testid"?: string;
193
+ }
194
+
195
+ /** One selectable row of a menu panel, at the kit's touch height. */
196
+ export function PopoverMenuRow({
197
+ label,
198
+ icon,
199
+ onSelect,
200
+ className,
201
+ lang,
202
+ "data-testid": testId,
203
+ }: PopoverMenuRowProps) {
204
+ return (
205
+ <button
206
+ type="button"
207
+ role="menuitem"
208
+ lang={lang}
209
+ onClick={onSelect}
210
+ data-testid={testId}
211
+ className={cn(
212
+ "flex min-h-11 items-center gap-3 px-4 py-2.5 text-left text-sm text-fg transition-colors hover:bg-surface-sunken",
213
+ className,
214
+ )}
215
+ >
216
+ {icon && <span className="shrink-0 text-fg-subtle">{icon}</span>}
217
+ {label}
218
+ </button>
219
+ );
220
+ }
221
+
14
222
  export interface PopoverMenuProps {
15
223
  /** Accessible label for the trigger button. */
16
224
  triggerLabel: string;
@@ -65,16 +273,18 @@ export function PopoverMenu({
65
273
  }: PopoverMenuProps) {
66
274
  const [open, setOpen] = useState(false);
67
275
  const containerRef = useRef<HTMLDivElement>(null);
276
+ const panelRef = useRef<HTMLDivElement>(null);
68
277
 
69
278
  useEffect(() => {
70
279
  if (!open) return;
71
280
  const onPointer = (event: MouseEvent) => {
281
+ const target = event.target as Node;
72
282
  if (
73
- containerRef.current &&
74
- !containerRef.current.contains(event.target as Node)
75
- ) {
76
- setOpen(false);
77
- }
283
+ containerRef.current?.contains(target) ||
284
+ panelRef.current?.contains(target)
285
+ )
286
+ return;
287
+ setOpen(false);
78
288
  };
79
289
  const onKey = (event: KeyboardEvent) => {
80
290
  if (event.key === "Escape") setOpen(false);
@@ -108,34 +318,27 @@ export function PopoverMenu({
108
318
  >
109
319
  {triggerText}
110
320
  </Button>
111
- {open && (
112
- <div
113
- role="menu"
114
- className={cn(
115
- "absolute top-full z-50 mt-1 flex max-h-[60dvh] min-w-44 flex-col overflow-y-auto overscroll-contain rounded-md border border-line bg-surface py-1 shadow-lg",
116
- align === "end" ? "right-0" : "left-0",
117
- )}
118
- >
321
+ <PopoverMenuPortal
322
+ open={open}
323
+ align={align}
324
+ panelRef={panelRef}
325
+ getAnchor={() => containerRef.current?.getBoundingClientRect() ?? null}
326
+ >
327
+ <PopoverMenuPanel ref={panelRef}>
119
328
  {items.map((item) => (
120
- <button
329
+ <PopoverMenuRow
121
330
  key={item.key}
122
- type="button"
123
- role="menuitem"
124
- onClick={() => {
331
+ label={item.label}
332
+ icon={item.icon}
333
+ onSelect={() => {
125
334
  setOpen(false);
126
335
  item.onSelect();
127
336
  }}
128
- className="flex min-h-11 items-center gap-3 px-4 py-2.5 text-left text-sm text-fg transition-colors hover:bg-surface-sunken"
129
- >
130
- {item.icon && (
131
- <span className="shrink-0 text-fg-subtle">{item.icon}</span>
132
- )}
133
- {item.label}
134
- </button>
337
+ />
135
338
  ))}
136
339
  {children}
137
- </div>
138
- )}
340
+ </PopoverMenuPanel>
341
+ </PopoverMenuPortal>
139
342
  </div>
140
343
  );
141
344
  }
@@ -1,7 +1,7 @@
1
1
  import type { ReactElement } from "react";
2
- import { useEffect, useState } from "react";
3
2
  import ReactPullToRefresh from "react-simple-pull-to-refresh";
4
3
  import { DESKTOP_MEDIA_QUERY } from "../lib/layout-breakpoints.js";
4
+ import { useMatchMedia } from "../lib/use-match-media.js";
5
5
 
6
6
  export interface PullToRefreshProps {
7
7
  children: ReactElement;
@@ -9,24 +9,6 @@ export interface PullToRefreshProps {
9
9
  isRefreshing?: boolean;
10
10
  }
11
11
 
12
- const useMatchMedia = (query: string): boolean => {
13
- const [matches, setMatches] = useState(() => {
14
- if (typeof window === "undefined" || !window.matchMedia) return false;
15
- return window.matchMedia(query).matches;
16
- });
17
-
18
- useEffect(() => {
19
- if (typeof window === "undefined" || !window.matchMedia) return;
20
- const mql = window.matchMedia(query);
21
- setMatches(mql.matches);
22
- const handler = (event: MediaQueryListEvent) => setMatches(event.matches);
23
- mql.addEventListener("change", handler);
24
- return () => mql.removeEventListener("change", handler);
25
- }, [query]);
26
-
27
- return matches;
28
- };
29
-
30
12
  /**
31
13
  * Wraps a scrollable list with a pull-to-refresh gesture on mobile. Below the
32
14
  * desktop breakpoint (Tailwind `lg`) a downward pull at the top of the list
@@ -0,0 +1,220 @@
1
+ import { BookPlus, EyeOff } from "lucide-react";
2
+ import { useEffect, useRef } from "react";
3
+ import { DESKTOP_MEDIA_QUERY } from "../lib/layout-breakpoints.js";
4
+ import { useRovingFocus } from "../lib/roving-focus.js";
5
+ import { useMatchMedia } from "../lib/use-match-media.js";
6
+ import { BottomSheet } from "./bottom-sheet.js";
7
+ import {
8
+ type PopoverMenuAnchor,
9
+ PopoverMenuPanel,
10
+ PopoverMenuPortal,
11
+ PopoverMenuRow,
12
+ } from "./popover-menu.js";
13
+
14
+ const SKELETON_WIDTHS = ["w-28", "w-20", "w-24"];
15
+
16
+ export type CorrectionMenuAnchor = PopoverMenuAnchor;
17
+
18
+ export interface RichTextCorrectionMenuProps {
19
+ word: string;
20
+ /** Null while the checker is still answering. */
21
+ suggestions: readonly string[] | null;
22
+ /** What the checker said when it could not answer. */
23
+ failure: string | null;
24
+ anchor: CorrectionMenuAnchor;
25
+ language: string;
26
+ onReplace: (suggestion: string) => void;
27
+ onIgnore: () => void;
28
+ /** Rendered only when the mount carries somewhere to put the word. */
29
+ onAddWord?: () => void;
30
+ /**
31
+ * `returnFocus` is false when the writer's own click is already on its way
32
+ * somewhere else, and taking the caret back would fight it.
33
+ */
34
+ onDismiss: (returnFocus: boolean) => void;
35
+ }
36
+
37
+ const CorrectionRows = ({
38
+ word,
39
+ suggestions,
40
+ failure,
41
+ language,
42
+ onReplace,
43
+ onIgnore,
44
+ onAddWord,
45
+ }: Omit<RichTextCorrectionMenuProps, "anchor" | "onDismiss">) => (
46
+ <>
47
+ <p
48
+ lang={language}
49
+ data-testid="spell-word"
50
+ className="px-4 pb-1 pt-1.5 text-xs font-medium text-fg-subtle"
51
+ >
52
+ {word}
53
+ </p>
54
+ {suggestions === null && failure === null
55
+ ? SKELETON_WIDTHS.map((width) => (
56
+ <div
57
+ key={width}
58
+ aria-hidden="true"
59
+ data-testid="spell-suggestion-skeleton"
60
+ className="flex min-h-11 items-center px-4 py-2.5"
61
+ >
62
+ <span
63
+ className={`h-3 animate-pulse rounded bg-surface-sunken ${width}`}
64
+ />
65
+ </div>
66
+ ))
67
+ : null}
68
+ {failure !== null && (
69
+ <p
70
+ role="alert"
71
+ data-testid="spell-suggestions-failed"
72
+ className="px-4 py-2 text-sm text-danger"
73
+ >
74
+ No suggestions came back: {failure}
75
+ </p>
76
+ )}
77
+ {suggestions?.length === 0 && (
78
+ <p
79
+ data-testid="spell-no-suggestions"
80
+ className="px-4 py-2 text-sm text-fg-muted"
81
+ >
82
+ No suggestions for this word.
83
+ </p>
84
+ )}
85
+ {suggestions?.map((suggestion) => (
86
+ <PopoverMenuRow
87
+ key={suggestion}
88
+ label={suggestion}
89
+ lang={language}
90
+ data-testid="spell-suggestion"
91
+ className="font-medium"
92
+ onSelect={() => onReplace(suggestion)}
93
+ />
94
+ ))}
95
+ <hr className="my-1 border-line" />
96
+ <PopoverMenuRow
97
+ label="Ignore for now"
98
+ icon={<EyeOff className="size-4" />}
99
+ data-testid="spell-ignore"
100
+ onSelect={onIgnore}
101
+ />
102
+ {onAddWord && (
103
+ <PopoverMenuRow
104
+ label="Add to dictionary"
105
+ icon={<BookPlus className="size-4" />}
106
+ data-testid="spell-add-word"
107
+ onSelect={onAddWord}
108
+ />
109
+ )}
110
+ </>
111
+ );
112
+
113
+ /**
114
+ * The corrections offered for one misspelt word. The menu is up before the
115
+ * checker has answered — skeleton rows stand where the suggestions will be —
116
+ * and it never goes quiet: a request that failed says so where the suggestions
117
+ * would have been, and ignoring the word stays reachable either way.
118
+ *
119
+ * Below the desktop gate the same rows are a sheet, because a popover anchored
120
+ * to a word sits under the thumb that opened it.
121
+ */
122
+ export const RichTextCorrectionMenu = ({
123
+ anchor,
124
+ onDismiss,
125
+ ...rows
126
+ }: RichTextCorrectionMenuProps) => {
127
+ const desktop = useMatchMedia(DESKTOP_MEDIA_QUERY);
128
+ const panelRef = useRef<HTMLDivElement>(null);
129
+
130
+ useRovingFocus({ containerRef: panelRef, itemSelector: '[role="menuitem"]' });
131
+
132
+ // The menu takes focus as it opens, so Escape and the arrow keys have
133
+ // somewhere to land rather than staying behind in the message.
134
+ useEffect(() => {
135
+ panelRef.current?.focus();
136
+ }, []);
137
+
138
+ // A press rather than a click, and a pointer press rather than a mouse one:
139
+ // the tap that opened this menu is followed by the browser's compatibility
140
+ // mouse events, which a `mousedown` listener reads as a press somewhere else
141
+ // and closes the menu on the way up.
142
+ //
143
+ // Escape is caught here too, at the document rather than the panel: the
144
+ // panel takes focus on mount so the panel's own `onKeyDown` ordinarily
145
+ // gets there first and this one never fires (its `stopPropagation` keeps
146
+ // the keydown from reaching here), but a focus call that lands a beat
147
+ // later than the browser is ready for is not something to depend on —
148
+ // this is what actually closes the menu when that happens.
149
+ useEffect(() => {
150
+ if (!desktop) return;
151
+ const onPointer = (event: Event) => {
152
+ if (panelRef.current?.contains(event.target as Node)) return;
153
+ onDismiss(false);
154
+ };
155
+ const onDocumentKeyDown = (event: KeyboardEvent) => {
156
+ if (event.key !== "Escape") return;
157
+ onDismiss(true);
158
+ };
159
+ document.addEventListener("pointerdown", onPointer);
160
+ document.addEventListener("keydown", onDocumentKeyDown);
161
+ return () => {
162
+ document.removeEventListener("pointerdown", onPointer);
163
+ document.removeEventListener("keydown", onDocumentKeyDown);
164
+ };
165
+ }, [desktop, onDismiss]);
166
+
167
+ /**
168
+ * Escape closes it, and so does Tab: a menu is not a dialog, and the repo
169
+ * traps focus nowhere. Letting Tab walk on would put the caret behind an
170
+ * open sheet, so the menu goes first and the message has the focus back
171
+ * before the next stop.
172
+ */
173
+ const onKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
174
+ if (event.key !== "Escape" && event.key !== "Tab") return;
175
+ event.preventDefault();
176
+ event.stopPropagation();
177
+ onDismiss(true);
178
+ };
179
+
180
+ if (!desktop) {
181
+ return (
182
+ <BottomSheet
183
+ open
184
+ onClose={() => onDismiss(true)}
185
+ dismissLabel="Close corrections"
186
+ >
187
+ <div
188
+ ref={panelRef}
189
+ role="menu"
190
+ aria-label={`Corrections for ${rows.word}`}
191
+ tabIndex={-1}
192
+ onKeyDown={onKeyDown}
193
+ data-testid="spell-menu"
194
+ className="flex flex-col overflow-y-auto pb-4 outline-none"
195
+ >
196
+ <CorrectionRows {...rows} />
197
+ </div>
198
+ </BottomSheet>
199
+ );
200
+ }
201
+
202
+ return (
203
+ <PopoverMenuPortal
204
+ open
205
+ align="start"
206
+ panelRef={panelRef}
207
+ getAnchor={() => anchor}
208
+ >
209
+ <PopoverMenuPanel
210
+ ref={panelRef}
211
+ label={`Corrections for ${rows.word}`}
212
+ onKeyDown={onKeyDown}
213
+ data-testid="spell-menu"
214
+ className="max-w-64"
215
+ >
216
+ <CorrectionRows {...rows} />
217
+ </PopoverMenuPanel>
218
+ </PopoverMenuPortal>
219
+ );
220
+ };