@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,178 @@
1
+ import { ChevronLeft, ChevronRight } from "lucide-react";
2
+ import { type ReactNode, useId } from "react";
3
+ import { cn } from "../lib/cn.js";
4
+ import type { CalendarViewId } from "./calendar-types.js";
5
+
6
+ export interface SegmentOption<T extends string> {
7
+ value: T;
8
+ label: string;
9
+ /** Shown instead of `label` where the control has to fit a thumb. */
10
+ shortLabel: string;
11
+ }
12
+
13
+ /**
14
+ * A flat choice, drawn the way the nav sidebar draws a picked mailbox: weight
15
+ * and hue mark what is on, and nothing is raised. The calendar's toolbar and the
16
+ * nav are the same act — choosing what the pane shows — so they look the same.
17
+ */
18
+ export function segmentClassName(active: boolean): string {
19
+ return cn(
20
+ "flex cursor-pointer items-center justify-center rounded-md px-2 font-medium transition-colors",
21
+ "has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-ring",
22
+ active
23
+ ? "bg-accent-2-soft text-accent-2"
24
+ : "text-fg-muted hover:bg-surface-sunken hover:text-fg",
25
+ );
26
+ }
27
+
28
+ export interface SegmentedProps<T extends string> {
29
+ options: SegmentOption<T>[];
30
+ value: T;
31
+ onChange: (value: T) => void;
32
+ /** Grows every segment to a 44px touch target. */
33
+ touch?: boolean;
34
+ ariaLabel: string;
35
+ className?: string;
36
+ }
37
+
38
+ function Segmented<T extends string>({
39
+ options,
40
+ value,
41
+ onChange,
42
+ touch,
43
+ ariaLabel,
44
+ className,
45
+ }: SegmentedProps<T>) {
46
+ const group = useId();
47
+ return (
48
+ <fieldset className={cn("inline-flex items-center gap-0.5", className)}>
49
+ <legend className="sr-only">{ariaLabel}</legend>
50
+ {options.map((option) => (
51
+ <label
52
+ key={option.value}
53
+ className={cn(
54
+ segmentClassName(value === option.value),
55
+ touch ? "min-h-11 flex-1 text-sm" : "h-7 text-xs",
56
+ )}
57
+ >
58
+ <input
59
+ type="radio"
60
+ name={group}
61
+ value={option.value}
62
+ checked={value === option.value}
63
+ onChange={() => onChange(option.value)}
64
+ className="sr-only"
65
+ />
66
+ {touch ? option.shortLabel : option.label}
67
+ </label>
68
+ ))}
69
+ </fieldset>
70
+ );
71
+ }
72
+
73
+ const VIEW_OPTIONS: SegmentOption<CalendarViewId>[] = [
74
+ { value: "year", label: "Year", shortLabel: "Y" },
75
+ { value: "month", label: "Month", shortLabel: "M" },
76
+ { value: "week", label: "Week", shortLabel: "W" },
77
+ { value: "day", label: "Day", shortLabel: "D" },
78
+ { value: "agenda", label: "Agenda", shortLabel: "List" },
79
+ ];
80
+
81
+ export interface CalendarViewSwitchProps {
82
+ value: CalendarViewId;
83
+ onChange: (view: CalendarViewId) => void;
84
+ /** Restricts the ladder — a phone has no room for a year grid. */
85
+ views?: CalendarViewId[];
86
+ touch?: boolean;
87
+ className?: string;
88
+ }
89
+
90
+ /**
91
+ * The zoom ladder. Each step keeps the date in view, so moving between them is
92
+ * changing magnification rather than opening a different screen.
93
+ */
94
+ export function CalendarViewSwitch({
95
+ value,
96
+ onChange,
97
+ views,
98
+ touch,
99
+ className,
100
+ }: CalendarViewSwitchProps) {
101
+ const options = views
102
+ ? VIEW_OPTIONS.filter((option) => views.includes(option.value))
103
+ : VIEW_OPTIONS;
104
+ return (
105
+ <Segmented
106
+ ariaLabel="Calendar view"
107
+ options={options}
108
+ value={value}
109
+ onChange={onChange}
110
+ touch={touch}
111
+ className={className}
112
+ />
113
+ );
114
+ }
115
+
116
+ export interface CalendarDateNavProps {
117
+ /** The range on screen, already formatted. */
118
+ title: string;
119
+ onPrev: () => void;
120
+ onNext: () => void;
121
+ onToday: () => void;
122
+ /** Right-hand controls: the view switch, whatever else the view owns. */
123
+ children?: ReactNode;
124
+ touch?: boolean;
125
+ }
126
+
127
+ /**
128
+ * Back, forward, and Today. Today lands on the same target from every view and
129
+ * every distance away, so it is a way home rather than a jump whose result
130
+ * depends on where you were.
131
+ */
132
+ export function CalendarDateNav({
133
+ title,
134
+ onPrev,
135
+ onNext,
136
+ onToday,
137
+ children,
138
+ touch,
139
+ }: CalendarDateNavProps) {
140
+ const stepClass = cn(
141
+ "flex items-center justify-center rounded-md text-fg-muted outline-none transition-colors hover:bg-surface-sunken hover:text-fg focus-visible:ring-2 focus-visible:ring-ring",
142
+ touch ? "size-11" : "size-7",
143
+ );
144
+ return (
145
+ <div className="flex min-w-0 items-center gap-2">
146
+ <button
147
+ type="button"
148
+ onClick={onPrev}
149
+ aria-label="Previous"
150
+ className={stepClass}
151
+ >
152
+ <ChevronLeft className="size-4" />
153
+ </button>
154
+ <button
155
+ type="button"
156
+ onClick={onNext}
157
+ aria-label="Next"
158
+ className={stepClass}
159
+ >
160
+ <ChevronRight className="size-4" />
161
+ </button>
162
+ <button
163
+ type="button"
164
+ onClick={onToday}
165
+ className={cn(
166
+ "rounded-md border border-line px-2.5 font-medium text-fg outline-none transition-colors hover:bg-surface-sunken focus-visible:ring-2 focus-visible:ring-ring",
167
+ touch ? "min-h-11 text-sm" : "h-7 text-xs",
168
+ )}
169
+ >
170
+ Today
171
+ </button>
172
+ <h2 className="min-w-0 flex-1 truncate text-sm font-semibold text-fg">
173
+ {title}
174
+ </h2>
175
+ {children}
176
+ </div>
177
+ );
178
+ }
@@ -0,0 +1,135 @@
1
+ /**
2
+ * The shapes the calendar surfaces pass around. Presentational only: a
3
+ * component here takes one of these and renders it, and every mutation leaves
4
+ * through a callback. Nothing in this file knows where the data came from.
5
+ */
6
+
7
+ /** One of the six per-calendar hues declared in `tokens.css`. */
8
+ export type CalendarColorId =
9
+ | "cal-1"
10
+ | "cal-2"
11
+ | "cal-3"
12
+ | "cal-4"
13
+ | "cal-5"
14
+ | "cal-6";
15
+
16
+ export const calendarColorIds: CalendarColorId[] = [
17
+ "cal-1",
18
+ "cal-2",
19
+ "cal-3",
20
+ "cal-4",
21
+ "cal-5",
22
+ "cal-6",
23
+ ];
24
+
25
+ export type RsvpState = "accepted" | "tentative" | "declined" | "noReply";
26
+
27
+ export interface CalendarAttendee {
28
+ name: string;
29
+ email: string;
30
+ rsvp: RsvpState;
31
+ /** The one attendee who owns the invitation, if any is known. */
32
+ role: "organizer" | "attendee";
33
+ }
34
+
35
+ export interface CalendarDescriptor {
36
+ id: string;
37
+ /** The mail account this calendar belongs to, so both nav trees line up. */
38
+ accountId: string;
39
+ accountLabel: string;
40
+ name: string;
41
+ color: CalendarColorId;
42
+ }
43
+
44
+ /**
45
+ * How much the source told us about the zone. `ambiguous` is a first-class
46
+ * state: a mail that said "3pm" and nothing else has no zone, and the UI says
47
+ * so rather than assuming the reader's.
48
+ */
49
+ export type ZoneCertainty = "local" | "explicit" | "ambiguous";
50
+
51
+ export interface CalendarEventData {
52
+ id: string;
53
+ calendarId: string;
54
+ title: string;
55
+ /** ISO 8601 with an explicit offset. */
56
+ start: string;
57
+ end: string;
58
+ allDay: boolean;
59
+ location: string;
60
+ notes: string;
61
+ attendees: CalendarAttendee[];
62
+ /**
63
+ * The reader's own reply. Kept apart from `attendees`: how an event is drawn
64
+ * turns on what you said, not on what somebody else said.
65
+ */
66
+ myRsvp: RsvpState;
67
+ /** The thread this event came from; empty when it was typed by hand. */
68
+ threadId: string;
69
+ threadSubject: string;
70
+ /** IANA zone, or empty when the source never said. */
71
+ timeZone: string;
72
+ zoneCertainty: ZoneCertainty;
73
+ /** The rule in words — "Every weekday, 09:15". Empty when it does not repeat. */
74
+ recurrenceRule: string;
75
+ /** Groups every instance of one series under a single object. */
76
+ seriesId: string;
77
+ /**
78
+ * This instance was moved or rewritten away from the rule and no longer
79
+ * matches it. The series still owns it, so the next edit still asks scope.
80
+ */
81
+ seriesException: boolean;
82
+ status: "confirmed" | "tentative";
83
+ }
84
+
85
+ /**
86
+ * A candidate read out of mail. It is deliberately not a `CalendarEventData`:
87
+ * nothing reaches the grid until a person confirms it, so a suggestion cannot
88
+ * be mistaken for an event by type, let alone by pixel.
89
+ */
90
+ export interface EventSuggestion {
91
+ id: string;
92
+ title: string;
93
+ start: string;
94
+ end: string;
95
+ allDay: boolean;
96
+ location: string;
97
+ /** The mail it was read out of. Always present — there is no other source. */
98
+ threadId: string;
99
+ threadSubject: string;
100
+ sender: string;
101
+ /** 0–1. The component derives its own wording; the seam carries the value. */
102
+ confidence: number;
103
+ /** What the parse could not settle, empty when nothing. */
104
+ ambiguity: string;
105
+ suggestedCalendarId: string;
106
+ timeZone: string;
107
+ zoneCertainty: ZoneCertainty;
108
+ }
109
+
110
+ /** Which instances of a series an edit applies to, chosen before the edit. */
111
+ export type RecurrenceScope = "this" | "following" | "all";
112
+
113
+ /**
114
+ * The zoom levels of one continuous strip of time, widest first. They are not
115
+ * separate screens: changing one keeps the date you were looking at.
116
+ */
117
+ export type CalendarViewId = "year" | "month" | "week" | "day" | "agenda";
118
+
119
+ /** What the editor holds while an event is being written. */
120
+ export interface EventDraft {
121
+ title: string;
122
+ /** `YYYY-MM-DD`. */
123
+ date: string;
124
+ /** `HH:MM`, empty when the entry is all day. */
125
+ startTime: string;
126
+ endTime: string;
127
+ allDay: boolean;
128
+ calendarId: string;
129
+ location: string;
130
+ /** Free text, one guest per comma — the prototype does not resolve them. */
131
+ guests: string;
132
+ notes: string;
133
+ /** Human-readable rule, empty for a one-off. */
134
+ repeat: string;
135
+ }
@@ -0,0 +1,106 @@
1
+ import type { Meta, StoryObj } from "@storybook/react-vite";
2
+ import { useState } from "react";
3
+ import {
4
+ type CustomRecurrence,
5
+ defaultCustomRecurrence,
6
+ defaultEndDate,
7
+ formatCustomRecurrence,
8
+ } from "../lib/recurrence.js";
9
+ import { CustomRecurrenceEditor } from "./custom-recurrence.js";
10
+
11
+ const DATE = "2026-06-09";
12
+ const START = "09:15";
13
+
14
+ /**
15
+ * The rule the derived choices cannot express. Every part of it is a control —
16
+ * how often, which days, when it stops — and what comes out is the same
17
+ * sentence the picker reads back. Nobody types an RRULE and nobody is shown
18
+ * one; the line under each story is what the event would carry.
19
+ */
20
+ const meta: Meta<typeof CustomRecurrenceEditor> = {
21
+ title: "Calendar/Custom recurrence",
22
+ component: CustomRecurrenceEditor,
23
+ parameters: { layout: "padded" },
24
+ };
25
+ export default meta;
26
+
27
+ type Story = StoryObj<typeof CustomRecurrenceEditor>;
28
+
29
+ function Editor({ seed, touch }: { seed: CustomRecurrence; touch?: boolean }) {
30
+ const [rule, setRule] = useState(seed);
31
+ return (
32
+ <div className="flex max-w-sm flex-col gap-4 rounded-lg border border-line bg-surface p-5">
33
+ <h3 className="text-lg font-semibold text-fg">Custom recurrence</h3>
34
+ <CustomRecurrenceEditor
35
+ value={rule}
36
+ onChange={setRule}
37
+ date={DATE}
38
+ suggestedEndDate={defaultEndDate(DATE)}
39
+ touch={touch}
40
+ />
41
+ <p className="border-t border-line pt-3 text-sm text-fg-muted">
42
+ {formatCustomRecurrence(rule, DATE, START)}
43
+ </p>
44
+ </div>
45
+ );
46
+ }
47
+
48
+ /** What it opens as: every week, on the day the event already sits on. */
49
+ export const Weekly: Story = {
50
+ render: () => <Editor seed={defaultCustomRecurrence(DATE)} />,
51
+ };
52
+
53
+ /**
54
+ * Two days a week, fortnightly, until October. None of that is expressible as
55
+ * a choice derived from a date, which is the whole reason this exists.
56
+ */
57
+ export const EveryOtherWeekUntilOctober: Story = {
58
+ name: "Every other week, until October",
59
+ render: () => (
60
+ <Editor
61
+ seed={{
62
+ interval: 2,
63
+ unit: "week",
64
+ weekdays: [1, 4],
65
+ monthlyMode: "dayOfMonth",
66
+ ends: { kind: "onDate", date: "2026-10-03" },
67
+ }}
68
+ />
69
+ ),
70
+ };
71
+
72
+ /**
73
+ * A monthly rule means two different things about the same date — the ninth,
74
+ * or the second Tuesday — and which one is meant is never derivable. So it is
75
+ * asked rather than assumed.
76
+ */
77
+ export const MonthlyReadTwoWays: Story = {
78
+ name: "Monthly, read two ways",
79
+ render: () => (
80
+ <Editor
81
+ seed={{
82
+ ...defaultCustomRecurrence(DATE),
83
+ unit: "month",
84
+ monthlyMode: "weekdayOfMonth",
85
+ }}
86
+ />
87
+ ),
88
+ };
89
+
90
+ /** An ending counted rather than dated: thirteen times and then it stops. */
91
+ export const EndsAfterCount: Story = {
92
+ name: "Ends after a count",
93
+ render: () => (
94
+ <Editor
95
+ seed={{
96
+ ...defaultCustomRecurrence(DATE),
97
+ ends: { kind: "afterCount", count: 13 },
98
+ }}
99
+ />
100
+ ),
101
+ };
102
+
103
+ /** The same controls at thumb size, as the phone step renders them. */
104
+ export const Touch: Story = {
105
+ render: () => <Editor seed={defaultCustomRecurrence(DATE)} touch />,
106
+ };