@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
@@ -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
+ };