@remit/ui 0.0.113 → 0.0.115

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 (63) hide show
  1. package/package.json +3 -3
  2. package/src/components/app-shell-slotted.render.test.ts +83 -0
  3. package/src/components/app-shell-slotted.tsx +31 -3
  4. package/src/components/attendee-row.render.test.ts +73 -0
  5. package/src/components/attendee-row.stories.tsx +68 -0
  6. package/src/components/attendee-row.tsx +96 -0
  7. package/src/components/calendar-event-chip.render.test.ts +67 -0
  8. package/src/components/calendar-event-chip.stories.tsx +128 -0
  9. package/src/components/calendar-event-chip.tsx +116 -0
  10. package/src/components/calendar-list.render.test.ts +77 -0
  11. package/src/components/calendar-list.stories.tsx +130 -0
  12. package/src/components/calendar-list.tsx +225 -0
  13. package/src/components/calendar-toolbar.render.test.ts +69 -0
  14. package/src/components/calendar-toolbar.stories.tsx +55 -0
  15. package/src/components/calendar-toolbar.tsx +178 -0
  16. package/src/components/calendar-types.ts +135 -0
  17. package/src/components/custom-recurrence.stories.tsx +106 -0
  18. package/src/components/custom-recurrence.tsx +411 -0
  19. package/src/components/event-detail.render.test.ts +104 -0
  20. package/src/components/event-detail.stories.tsx +178 -0
  21. package/src/components/event-detail.tsx +188 -0
  22. package/src/components/event-editor-pane.tsx +54 -0
  23. package/src/components/event-editor.render.test.ts +83 -0
  24. package/src/components/event-editor.stories.tsx +99 -0
  25. package/src/components/event-editor.tsx +480 -0
  26. package/src/components/event-quick-entry.render.test.ts +40 -0
  27. package/src/components/event-quick-entry.stories.tsx +56 -0
  28. package/src/components/event-quick-entry.tsx +159 -0
  29. package/src/components/event-suggestion-card.render.test.ts +65 -0
  30. package/src/components/event-suggestion-card.stories.tsx +93 -0
  31. package/src/components/event-suggestion-card.tsx +116 -0
  32. package/src/components/flow-screen.tsx +169 -0
  33. package/src/components/intelligence-panel.stories.tsx +5 -1
  34. package/src/components/intelligence-panel.tsx +4 -0
  35. package/src/components/isolated-email-frame.tsx +1 -18
  36. package/src/components/message-list-pane.render.test.ts +2 -2
  37. package/src/components/message-list-pane.stories.tsx +1 -1
  38. package/src/components/nav-sidebar.tsx +20 -0
  39. package/src/components/popover-menu.tsx +230 -27
  40. package/src/components/pull-to-refresh.tsx +1 -19
  41. package/src/components/recurrence-scope-prompt.render.test.ts +36 -0
  42. package/src/components/recurrence-scope-prompt.stories.tsx +46 -0
  43. package/src/components/recurrence-scope-prompt.tsx +84 -0
  44. package/src/components/rich-text-correction-menu.tsx +220 -0
  45. package/src/components/rich-text-editor.stories.tsx +507 -5
  46. package/src/components/rich-text-editor.tsx +482 -83
  47. package/src/components/rich-text-spellcheck-menu.test.ts +865 -0
  48. package/src/components/rich-text-spellcheck-provider.test.ts +114 -1
  49. package/src/components/rich-text-spellcheck-provider.ts +68 -3
  50. package/src/components/rich-text-spellcheck-words.ts +168 -4
  51. package/src/components/rich-text-spellcheck-worker.ts +11 -0
  52. package/src/components/rich-text-spellcheck.test.ts +7 -0
  53. package/src/components/rich-text-spellcheck.ts +28 -2
  54. package/src/components/selection-wizard.tsx +19 -69
  55. package/src/index.ts +116 -0
  56. package/src/lib/calendar-color.ts +71 -0
  57. package/src/lib/event-phrase.test.ts +89 -0
  58. package/src/lib/event-phrase.ts +228 -0
  59. package/src/lib/recurrence.test.ts +141 -0
  60. package/src/lib/recurrence.ts +266 -0
  61. package/src/lib/use-match-media.ts +25 -0
  62. package/src/rich-text.ts +12 -0
  63. package/src/tokens.css +63 -0
@@ -93,13 +93,13 @@ describe("MessageListPane", () => {
93
93
  listState: "ready",
94
94
  listBody: createElement(
95
95
  "a",
96
- { href: "/mail/inbox?selectedMessageId=t1" },
96
+ { href: "/mail/inbox/thr-t1/t1" },
97
97
  "Q3 planning notes",
98
98
  ),
99
99
  }),
100
100
  );
101
101
  // The consumer's anchor row is rendered on the mobile/touch path…
102
- assert.match(html, /href="\/mail\/inbox\?selectedMessageId=t1"/);
102
+ assert.match(html, /href="\/mail\/inbox\/thr-t1\/t1"/);
103
103
  // …and the built-in TouchListBody mock fallback is NOT substituted.
104
104
  assert.doesNotMatch(html, /Pull to refresh/);
105
105
  });
@@ -229,7 +229,7 @@ export const CustomListBody: Story = {
229
229
  s.threads.map((t) => (
230
230
  <a
231
231
  key={t.id}
232
- href={`?selectedMessageId=${t.id}`}
232
+ href={`/mail/mbx-1/thr-${t.id}/${t.id}`}
233
233
  className="flex items-center gap-3 px-4 py-3 hover:bg-surface-sunken"
234
234
  >
235
235
  <span className="font-medium text-sm">{t.fromName}</span>
@@ -4,6 +4,7 @@ import {
4
4
  Archive,
5
5
  BellOff,
6
6
  BookmarkPlus,
7
+ CalendarDays,
7
8
  ChevronDown,
8
9
  ChevronRight,
9
10
  FileText,
@@ -558,6 +559,12 @@ export interface NavSidebarProps
558
559
  * fall back to programmatic buttons (static stories / AppShell preview).
559
560
  */
560
561
  linkComponent?: NavLinkComponent;
562
+ /**
563
+ * Whether the calendar destination sits under the daily brief. It is
564
+ * "hidden" until the app has a route behind it — the prototype turns it on,
565
+ * the shipping nav does not offer an entry that leads nowhere.
566
+ */
567
+ calendarNav?: "hidden" | "shown";
561
568
  /** Every saved query, most recently saved first. See `SavedSearchesGroup`. */
562
569
  savedSearches?: string[];
563
570
  /**
@@ -577,6 +584,7 @@ export function NavSidebar({
577
584
  onSelectNav,
578
585
  variant = "desktop",
579
586
  linkComponent,
587
+ calendarNav = "hidden",
580
588
  savedSearches = [],
581
589
  saveableQuery,
582
590
  onSelectSavedSearch,
@@ -604,6 +612,18 @@ export function NavSidebar({
604
612
  onClick={() => onSelectNav?.("brief")}
605
613
  />
606
614
 
615
+ {calendarNav === "shown" && (
616
+ <NavItem
617
+ navId="calendar"
618
+ linkComponent={linkComponent}
619
+ icon={<CalendarDays className="size-4" />}
620
+ label="Calendar"
621
+ ariaLabel="Calendar"
622
+ active={selectedNavId === "calendar"}
623
+ onClick={() => onSelectNav?.("calendar")}
624
+ />
625
+ )}
626
+
607
627
  <NavItem
608
628
  navId="flagged"
609
629
  linkComponent={linkComponent}
@@ -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,36 @@
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 { RecurrenceScopePrompt } from "./recurrence-scope-prompt.js";
6
+
7
+ const render = (touch = false) =>
8
+ renderToString(
9
+ createElement(RecurrenceScopePrompt, {
10
+ title: "Standup",
11
+ ruleText: "Every weekday, 09:15",
12
+ instanceText: "Wednesday 10 June",
13
+ onChoose: () => undefined,
14
+ onCancel: () => undefined,
15
+ touch,
16
+ }),
17
+ );
18
+
19
+ describe("RecurrenceScopePrompt", () => {
20
+ it("offers all three scopes", () => {
21
+ const html = render();
22
+ assert.match(html, /This event/);
23
+ assert.match(html, /This and everything after/);
24
+ assert.match(html, /The whole series/);
25
+ });
26
+
27
+ it("states the rule and the instance that was clicked", () => {
28
+ const html = render();
29
+ assert.match(html, /Every weekday, 09:15/);
30
+ assert.match(html, /Wednesday 10 June/);
31
+ });
32
+
33
+ it("grows the choices for a thumb", () => {
34
+ assert.match(render(true), /min-h-11/);
35
+ });
36
+ });
@@ -0,0 +1,46 @@
1
+ import type { Meta, StoryObj } from "@storybook/react-vite";
2
+ import { RecurrenceScopePrompt } from "./recurrence-scope-prompt.js";
3
+
4
+ /**
5
+ * The scope question, asked before the form opens. Editing one instance and
6
+ * editing the rule are different acts, so the choice is made while the change
7
+ * is still an intention.
8
+ */
9
+ const meta: Meta<typeof RecurrenceScopePrompt> = {
10
+ title: "Calendar/Recurrence scope",
11
+ component: RecurrenceScopePrompt,
12
+ parameters: { layout: "padded" },
13
+ };
14
+ export default meta;
15
+
16
+ type Story = StoryObj<typeof RecurrenceScopePrompt>;
17
+
18
+ export const Desktop: Story = {
19
+ render: () => (
20
+ <div className="max-w-sm rounded-lg border border-line bg-surface-raised p-4">
21
+ <RecurrenceScopePrompt
22
+ title="Standup"
23
+ ruleText="Every weekday, 09:15"
24
+ instanceText="Wednesday 10 June"
25
+ onChoose={() => {}}
26
+ onCancel={() => {}}
27
+ />
28
+ </div>
29
+ ),
30
+ };
31
+
32
+ /** The same question with thumb-sized targets. */
33
+ export const Touch: Story = {
34
+ render: () => (
35
+ <div className="max-w-sm rounded-lg border border-line bg-surface-raised p-4">
36
+ <RecurrenceScopePrompt
37
+ title="Standup"
38
+ ruleText="Every weekday, 09:15"
39
+ instanceText="Wednesday 10 June"
40
+ onChoose={() => {}}
41
+ onCancel={() => {}}
42
+ touch
43
+ />
44
+ </div>
45
+ ),
46
+ };
@@ -0,0 +1,84 @@
1
+ import { cn } from "../lib/cn.js";
2
+ import { Button } from "./button.js";
3
+ import type { RecurrenceScope } from "./calendar-types.js";
4
+
5
+ export interface RecurrenceScopePromptProps {
6
+ /** The series being edited, e.g. "Standup". */
7
+ title: string;
8
+ /** The rule in words, e.g. "Every weekday, 09:15". */
9
+ ruleText: string;
10
+ /** The instance that was clicked, already formatted. */
11
+ instanceText: string;
12
+ onChoose: (scope: RecurrenceScope) => void;
13
+ onCancel: () => void;
14
+ touch?: boolean;
15
+ className?: string;
16
+ }
17
+
18
+ const choices: { scope: RecurrenceScope; label: string; note: string }[] = [
19
+ {
20
+ scope: "this",
21
+ label: "This event",
22
+ note: "Leaves the rest of the series alone",
23
+ },
24
+ {
25
+ scope: "following",
26
+ label: "This and everything after",
27
+ note: "Splits the series here",
28
+ },
29
+ { scope: "all", label: "The whole series", note: "Rewrites the rule itself" },
30
+ ];
31
+
32
+ /**
33
+ * The scope question, asked first. A series is one object with one rule, and
34
+ * which instances an edit reaches changes what the edit means — so it is
35
+ * settled before the form opens, not confirmed on the way out when the change
36
+ * has already been typed.
37
+ */
38
+ export function RecurrenceScopePrompt({
39
+ title,
40
+ ruleText,
41
+ instanceText,
42
+ onChoose,
43
+ onCancel,
44
+ touch,
45
+ className,
46
+ }: RecurrenceScopePromptProps) {
47
+ return (
48
+ <div className={cn("flex flex-col gap-3", className)}>
49
+ <div>
50
+ <h2 className="text-md font-semibold text-fg">{title} repeats</h2>
51
+ <p className="text-xs text-fg-muted">{ruleText}</p>
52
+ <p className="text-xs text-fg-subtle">You clicked {instanceText}.</p>
53
+ </div>
54
+
55
+ <p className="text-sm text-fg">What should the change apply to?</p>
56
+
57
+ <div className="flex flex-col gap-1.5">
58
+ {choices.map((choice) => (
59
+ <button
60
+ key={choice.scope}
61
+ type="button"
62
+ onClick={() => onChoose(choice.scope)}
63
+ className={cn(
64
+ "flex flex-col rounded-md border border-line bg-surface px-3 py-2 text-left outline-none transition-colors hover:border-line-strong hover:bg-surface-sunken focus-visible:ring-2 focus-visible:ring-ring",
65
+ touch && "min-h-11 justify-center",
66
+ )}
67
+ >
68
+ <span className="text-sm font-medium text-fg">{choice.label}</span>
69
+ <span className="text-2xs text-fg-subtle">{choice.note}</span>
70
+ </button>
71
+ ))}
72
+ </div>
73
+
74
+ <Button
75
+ variant="ghost"
76
+ size={touch ? "md" : "sm"}
77
+ onClick={onCancel}
78
+ className={cn("self-start", touch && "min-h-11")}
79
+ >
80
+ Cancel
81
+ </Button>
82
+ </div>
83
+ );
84
+ }