@remit/ui 0.0.113 → 0.0.114
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 +1 -1
- package/src/components/app-shell-slotted.render.test.ts +83 -0
- package/src/components/app-shell-slotted.tsx +31 -3
- package/src/components/attendee-row.render.test.ts +73 -0
- package/src/components/attendee-row.stories.tsx +68 -0
- package/src/components/attendee-row.tsx +96 -0
- package/src/components/calendar-event-chip.render.test.ts +67 -0
- package/src/components/calendar-event-chip.stories.tsx +128 -0
- package/src/components/calendar-event-chip.tsx +116 -0
- package/src/components/calendar-list.render.test.ts +77 -0
- package/src/components/calendar-list.stories.tsx +130 -0
- package/src/components/calendar-list.tsx +225 -0
- package/src/components/calendar-toolbar.render.test.ts +69 -0
- package/src/components/calendar-toolbar.stories.tsx +55 -0
- package/src/components/calendar-toolbar.tsx +178 -0
- package/src/components/calendar-types.ts +135 -0
- package/src/components/custom-recurrence.stories.tsx +106 -0
- package/src/components/custom-recurrence.tsx +411 -0
- package/src/components/event-detail.render.test.ts +104 -0
- package/src/components/event-detail.stories.tsx +178 -0
- package/src/components/event-detail.tsx +188 -0
- package/src/components/event-editor-pane.tsx +54 -0
- package/src/components/event-editor.render.test.ts +83 -0
- package/src/components/event-editor.stories.tsx +99 -0
- package/src/components/event-editor.tsx +480 -0
- package/src/components/event-quick-entry.render.test.ts +40 -0
- package/src/components/event-quick-entry.stories.tsx +56 -0
- package/src/components/event-quick-entry.tsx +159 -0
- package/src/components/event-suggestion-card.render.test.ts +65 -0
- package/src/components/event-suggestion-card.stories.tsx +93 -0
- package/src/components/event-suggestion-card.tsx +116 -0
- package/src/components/flow-screen.tsx +169 -0
- package/src/components/intelligence-panel.stories.tsx +5 -1
- package/src/components/intelligence-panel.tsx +4 -0
- package/src/components/message-list-pane.render.test.ts +2 -2
- package/src/components/message-list-pane.stories.tsx +1 -1
- package/src/components/nav-sidebar.tsx +20 -0
- package/src/components/recurrence-scope-prompt.render.test.ts +36 -0
- package/src/components/recurrence-scope-prompt.stories.tsx +46 -0
- package/src/components/recurrence-scope-prompt.tsx +84 -0
- package/src/components/selection-wizard.tsx +19 -69
- package/src/index.ts +111 -0
- package/src/lib/calendar-color.ts +71 -0
- package/src/lib/event-phrase.test.ts +89 -0
- package/src/lib/event-phrase.ts +228 -0
- package/src/lib/recurrence.test.ts +141 -0
- package/src/lib/recurrence.ts +266 -0
- package/src/tokens.css +63 -0
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Clock,
|
|
3
|
+
Globe,
|
|
4
|
+
Mail,
|
|
5
|
+
MapPin,
|
|
6
|
+
Pencil,
|
|
7
|
+
Repeat,
|
|
8
|
+
Trash2,
|
|
9
|
+
X,
|
|
10
|
+
} from "lucide-react";
|
|
11
|
+
import { calendarColorClasses } from "../lib/calendar-color.js";
|
|
12
|
+
import { cn } from "../lib/cn.js";
|
|
13
|
+
import { AttendeeList, RsvpBadge } from "./attendee-row.js";
|
|
14
|
+
import { Button } from "./button.js";
|
|
15
|
+
import type {
|
|
16
|
+
CalendarDescriptor,
|
|
17
|
+
CalendarEventData,
|
|
18
|
+
} from "./calendar-types.js";
|
|
19
|
+
|
|
20
|
+
export interface EventDetailProps {
|
|
21
|
+
event: CalendarEventData;
|
|
22
|
+
calendar: CalendarDescriptor;
|
|
23
|
+
/** Already formatted by the caller — the component owns no clock. */
|
|
24
|
+
whenText: string;
|
|
25
|
+
onEdit: () => void;
|
|
26
|
+
onDelete: () => void;
|
|
27
|
+
/** Absent when the event was never born from a mail. */
|
|
28
|
+
onOpenThread?: () => void;
|
|
29
|
+
/** Puts the pane back to whatever it shows with nothing selected. */
|
|
30
|
+
onClose?: () => void;
|
|
31
|
+
/**
|
|
32
|
+
* `bare` drops the header and the scrolling frame, for a surface that already
|
|
33
|
+
* carries both — a full-screen flow whose footer holds the same actions.
|
|
34
|
+
*/
|
|
35
|
+
chrome?: "header" | "bare";
|
|
36
|
+
className?: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function Line({
|
|
40
|
+
icon,
|
|
41
|
+
children,
|
|
42
|
+
}: {
|
|
43
|
+
icon: React.ReactNode;
|
|
44
|
+
children: React.ReactNode;
|
|
45
|
+
}) {
|
|
46
|
+
return (
|
|
47
|
+
<div className="flex items-start gap-2.5 py-1">
|
|
48
|
+
<span className="mt-0.5 shrink-0 text-fg-subtle">{icon}</span>
|
|
49
|
+
<div className="min-w-0 flex-1 text-sm text-fg">{children}</div>
|
|
50
|
+
</div>
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* One event, opened. The link back to the mail it came from is part of the
|
|
56
|
+
* event rather than a footnote: an event with a thread behind it is never a
|
|
57
|
+
* dead end, and the way back is one click from the detail you are reading.
|
|
58
|
+
*/
|
|
59
|
+
export function EventDetail({
|
|
60
|
+
event,
|
|
61
|
+
calendar,
|
|
62
|
+
whenText,
|
|
63
|
+
onEdit,
|
|
64
|
+
onDelete,
|
|
65
|
+
onOpenThread,
|
|
66
|
+
onClose,
|
|
67
|
+
chrome = "header",
|
|
68
|
+
className,
|
|
69
|
+
}: EventDetailProps) {
|
|
70
|
+
const hue = calendarColorClasses(calendar.color);
|
|
71
|
+
|
|
72
|
+
const facts = (
|
|
73
|
+
<>
|
|
74
|
+
<h1 className="text-xl font-semibold text-fg">{event.title}</h1>
|
|
75
|
+
|
|
76
|
+
<div className="mt-3 flex flex-col border-b border-line pb-3">
|
|
77
|
+
<Line icon={<Clock className="size-4" />}>
|
|
78
|
+
<span className="flex flex-wrap items-center gap-2">
|
|
79
|
+
{whenText}
|
|
80
|
+
{event.attendees.length > 0 && <RsvpBadge rsvp={event.myRsvp} />}
|
|
81
|
+
</span>
|
|
82
|
+
</Line>
|
|
83
|
+
{event.zoneCertainty === "ambiguous" ? (
|
|
84
|
+
<Line icon={<Globe className="size-4 text-warning" />}>
|
|
85
|
+
<span className="text-warning">
|
|
86
|
+
The mail said a time but never a zone. Shown in your own; confirm
|
|
87
|
+
before you travel for it.
|
|
88
|
+
</span>
|
|
89
|
+
</Line>
|
|
90
|
+
) : (
|
|
91
|
+
event.timeZone !== "" && (
|
|
92
|
+
<Line icon={<Globe className="size-4" />}>{event.timeZone}</Line>
|
|
93
|
+
)
|
|
94
|
+
)}
|
|
95
|
+
{event.recurrenceRule !== "" && (
|
|
96
|
+
<Line icon={<Repeat className="size-4" />}>
|
|
97
|
+
<span className="flex flex-wrap items-center gap-2">
|
|
98
|
+
{event.recurrenceRule}
|
|
99
|
+
{event.seriesException && (
|
|
100
|
+
<span className="rounded-xs bg-warning-soft px-1.5 py-0.5 text-2xs text-warning">
|
|
101
|
+
Moved out of the series
|
|
102
|
+
</span>
|
|
103
|
+
)}
|
|
104
|
+
</span>
|
|
105
|
+
</Line>
|
|
106
|
+
)}
|
|
107
|
+
{event.location !== "" && (
|
|
108
|
+
<Line icon={<MapPin className="size-4" />}>{event.location}</Line>
|
|
109
|
+
)}
|
|
110
|
+
</div>
|
|
111
|
+
|
|
112
|
+
{event.threadId !== "" && onOpenThread && (
|
|
113
|
+
<button
|
|
114
|
+
type="button"
|
|
115
|
+
onClick={onOpenThread}
|
|
116
|
+
className="mt-3 flex w-full items-center gap-2.5 rounded-md border border-line bg-surface-sunken px-3 py-2 text-left outline-none hover:border-line-strong focus-visible:ring-2 focus-visible:ring-ring"
|
|
117
|
+
>
|
|
118
|
+
<Mail className="size-4 shrink-0 text-accent-2" />
|
|
119
|
+
<span className="min-w-0 flex-1">
|
|
120
|
+
<span className="block text-2xs uppercase tracking-wider text-fg-subtle">
|
|
121
|
+
From this thread
|
|
122
|
+
</span>
|
|
123
|
+
<span className="block truncate text-sm text-fg">
|
|
124
|
+
{event.threadSubject}
|
|
125
|
+
</span>
|
|
126
|
+
</span>
|
|
127
|
+
</button>
|
|
128
|
+
)}
|
|
129
|
+
|
|
130
|
+
{event.attendees.length > 0 && (
|
|
131
|
+
<AttendeeList attendees={event.attendees} className="mt-3" />
|
|
132
|
+
)}
|
|
133
|
+
|
|
134
|
+
{event.notes !== "" && (
|
|
135
|
+
<p className="mt-3 whitespace-pre-wrap text-sm text-fg-muted">
|
|
136
|
+
{event.notes}
|
|
137
|
+
</p>
|
|
138
|
+
)}
|
|
139
|
+
</>
|
|
140
|
+
);
|
|
141
|
+
|
|
142
|
+
if (chrome === "bare")
|
|
143
|
+
return <div className={cn("flex flex-col", className)}>{facts}</div>;
|
|
144
|
+
|
|
145
|
+
return (
|
|
146
|
+
<article
|
|
147
|
+
className={cn("flex h-full w-full flex-col bg-surface", className)}
|
|
148
|
+
>
|
|
149
|
+
<header className="flex h-pane-header shrink-0 items-center gap-2 border-b border-line px-row-inset">
|
|
150
|
+
<span className={cn("size-2.5 shrink-0 rounded-full", hue.solid)} />
|
|
151
|
+
<span className="min-w-0 flex-1 truncate text-xs text-fg-muted">
|
|
152
|
+
{calendar.name} · {calendar.accountLabel}
|
|
153
|
+
</span>
|
|
154
|
+
<Button
|
|
155
|
+
variant="ghost"
|
|
156
|
+
size="sm"
|
|
157
|
+
icon={<Pencil className="size-3.5" />}
|
|
158
|
+
onClick={onEdit}
|
|
159
|
+
>
|
|
160
|
+
Edit
|
|
161
|
+
</Button>
|
|
162
|
+
<Button
|
|
163
|
+
variant="ghost"
|
|
164
|
+
size="sm"
|
|
165
|
+
icon={<Trash2 className="size-3.5" />}
|
|
166
|
+
onClick={onDelete}
|
|
167
|
+
className="text-danger"
|
|
168
|
+
>
|
|
169
|
+
Delete
|
|
170
|
+
</Button>
|
|
171
|
+
{onClose && (
|
|
172
|
+
<button
|
|
173
|
+
type="button"
|
|
174
|
+
aria-label="Close event"
|
|
175
|
+
onClick={onClose}
|
|
176
|
+
className="flex size-7 items-center justify-center rounded-md text-fg-subtle outline-none hover:bg-surface-sunken hover:text-fg focus-visible:ring-2 focus-visible:ring-ring"
|
|
177
|
+
>
|
|
178
|
+
<X className="size-4" />
|
|
179
|
+
</button>
|
|
180
|
+
)}
|
|
181
|
+
</header>
|
|
182
|
+
|
|
183
|
+
<div className="min-h-0 flex-1 overflow-y-auto px-row-inset py-3">
|
|
184
|
+
{facts}
|
|
185
|
+
</div>
|
|
186
|
+
</article>
|
|
187
|
+
);
|
|
188
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { X } from "lucide-react";
|
|
2
|
+
import type { ReactNode } from "react";
|
|
3
|
+
import { cn } from "../lib/cn.js";
|
|
4
|
+
|
|
5
|
+
export interface EventEditorPaneProps {
|
|
6
|
+
/** What the pane is doing: "New event", "Edit event". */
|
|
7
|
+
title: string;
|
|
8
|
+
/** The one line of context the title cannot carry — which day, which series. */
|
|
9
|
+
subtitle?: string;
|
|
10
|
+
onClose: () => void;
|
|
11
|
+
children: ReactNode;
|
|
12
|
+
className?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Writing an event happens in the pane the event is read in, at the width that
|
|
17
|
+
* pane has. A form floating over the grid has to stay small enough not to bury
|
|
18
|
+
* it, which is how the fields got crushed; the pane is already where the reader
|
|
19
|
+
* looks after clicking something, it grows with the window, and it can be
|
|
20
|
+
* dragged wider when the form is the work.
|
|
21
|
+
*/
|
|
22
|
+
export function EventEditorPane({
|
|
23
|
+
title,
|
|
24
|
+
subtitle,
|
|
25
|
+
onClose,
|
|
26
|
+
children,
|
|
27
|
+
className,
|
|
28
|
+
}: EventEditorPaneProps) {
|
|
29
|
+
return (
|
|
30
|
+
<section
|
|
31
|
+
className={cn("flex h-full w-full flex-col bg-surface", className)}
|
|
32
|
+
>
|
|
33
|
+
<header className="flex h-pane-header shrink-0 items-center gap-2 border-b border-line px-row-inset">
|
|
34
|
+
<h2 className="shrink-0 text-xs font-medium text-fg">{title}</h2>
|
|
35
|
+
{subtitle !== undefined && subtitle !== "" && (
|
|
36
|
+
<span className="min-w-0 flex-1 truncate text-xs text-fg-muted">
|
|
37
|
+
{subtitle}
|
|
38
|
+
</span>
|
|
39
|
+
)}
|
|
40
|
+
<button
|
|
41
|
+
type="button"
|
|
42
|
+
aria-label="Close editor"
|
|
43
|
+
onClick={onClose}
|
|
44
|
+
className="ml-auto flex size-7 shrink-0 items-center justify-center rounded-md text-fg-subtle outline-none hover:bg-surface-sunken hover:text-fg focus-visible:ring-2 focus-visible:ring-ring"
|
|
45
|
+
>
|
|
46
|
+
<X className="size-4" />
|
|
47
|
+
</button>
|
|
48
|
+
</header>
|
|
49
|
+
<div className="min-h-0 flex-1 overflow-y-auto px-row-inset py-5">
|
|
50
|
+
<div className="mx-auto w-full max-w-2xl">{children}</div>
|
|
51
|
+
</div>
|
|
52
|
+
</section>
|
|
53
|
+
);
|
|
54
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
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 type { CalendarDescriptor, EventDraft } from "./calendar-types.js";
|
|
6
|
+
import { EventEditor } from "./event-editor.js";
|
|
7
|
+
|
|
8
|
+
const calendars: CalendarDescriptor[] = [
|
|
9
|
+
{
|
|
10
|
+
id: "c1",
|
|
11
|
+
accountId: "a1",
|
|
12
|
+
accountLabel: "Work",
|
|
13
|
+
name: "Northwind",
|
|
14
|
+
color: "cal-1",
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
id: "c2",
|
|
18
|
+
accountId: "a2",
|
|
19
|
+
accountLabel: "Personal",
|
|
20
|
+
name: "Family",
|
|
21
|
+
color: "cal-3",
|
|
22
|
+
},
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
const draft: EventDraft = {
|
|
26
|
+
title: "Coffee",
|
|
27
|
+
date: "2026-06-12",
|
|
28
|
+
startTime: "13:00",
|
|
29
|
+
endTime: "14:00",
|
|
30
|
+
allDay: false,
|
|
31
|
+
calendarId: "c1",
|
|
32
|
+
location: "",
|
|
33
|
+
guests: "",
|
|
34
|
+
notes: "",
|
|
35
|
+
repeat: "",
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const render = (props: Partial<Parameters<typeof EventEditor>[0]>) =>
|
|
39
|
+
renderToString(
|
|
40
|
+
createElement(EventEditor, {
|
|
41
|
+
draft,
|
|
42
|
+
onChange: () => undefined,
|
|
43
|
+
calendars,
|
|
44
|
+
expanded: false,
|
|
45
|
+
onToggleExpanded: () => undefined,
|
|
46
|
+
onSave: () => undefined,
|
|
47
|
+
onCancel: () => undefined,
|
|
48
|
+
...props,
|
|
49
|
+
}),
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
describe("EventEditor", () => {
|
|
53
|
+
it("folds everything but title, when and calendar", () => {
|
|
54
|
+
const html = render({});
|
|
55
|
+
assert.match(html, /aria-label="Title"/);
|
|
56
|
+
assert.match(html, /aria-label="Start time"/);
|
|
57
|
+
assert.match(html, /Northwind/);
|
|
58
|
+
assert.doesNotMatch(html, /aria-label="Location"/);
|
|
59
|
+
assert.match(html, /More details/);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("reveals the rest on request", () => {
|
|
63
|
+
const html = render({ expanded: true });
|
|
64
|
+
assert.match(html, /aria-label="Location"/);
|
|
65
|
+
assert.match(html, /aria-label="Guests"/);
|
|
66
|
+
assert.match(html, /aria-label="Repeat"/);
|
|
67
|
+
assert.match(html, /Fewer details/);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("drops the clock fields for an all-day entry", () => {
|
|
71
|
+
const html = render({ draft: { ...draft, allDay: true } });
|
|
72
|
+
assert.doesNotMatch(html, /aria-label="Start time"/);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("offers Delete only when editing something that exists", () => {
|
|
76
|
+
assert.doesNotMatch(render({}), /Delete/);
|
|
77
|
+
assert.match(render({ onDelete: () => undefined }), /Delete/);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("names the save action the caller asked for", () => {
|
|
81
|
+
assert.match(render({ saveLabel: "Save changes" }), /Save changes/);
|
|
82
|
+
});
|
|
83
|
+
});
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
2
|
+
import { useState } from "react";
|
|
3
|
+
import type { CalendarDescriptor, EventDraft } from "./calendar-types.js";
|
|
4
|
+
import { EventEditor } from "./event-editor.js";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Three fields make an event. Location, guests, notes and repeat are real and
|
|
8
|
+
* one click away, but they do not charge the common case for their existence.
|
|
9
|
+
*/
|
|
10
|
+
const meta: Meta<typeof EventEditor> = {
|
|
11
|
+
title: "Calendar/Event editor",
|
|
12
|
+
component: EventEditor,
|
|
13
|
+
parameters: { layout: "padded" },
|
|
14
|
+
};
|
|
15
|
+
export default meta;
|
|
16
|
+
|
|
17
|
+
type Story = StoryObj<typeof EventEditor>;
|
|
18
|
+
|
|
19
|
+
const calendars: CalendarDescriptor[] = [
|
|
20
|
+
{
|
|
21
|
+
id: "c1",
|
|
22
|
+
accountId: "a1",
|
|
23
|
+
accountLabel: "Work",
|
|
24
|
+
name: "Northwind",
|
|
25
|
+
color: "cal-1",
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
id: "c3",
|
|
29
|
+
accountId: "a2",
|
|
30
|
+
accountLabel: "Personal",
|
|
31
|
+
name: "Personal",
|
|
32
|
+
color: "cal-2",
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
id: "c4",
|
|
36
|
+
accountId: "a2",
|
|
37
|
+
accountLabel: "Personal",
|
|
38
|
+
name: "Family",
|
|
39
|
+
color: "cal-3",
|
|
40
|
+
},
|
|
41
|
+
];
|
|
42
|
+
|
|
43
|
+
const seed: EventDraft = {
|
|
44
|
+
title: "",
|
|
45
|
+
date: "2026-06-12",
|
|
46
|
+
startTime: "13:00",
|
|
47
|
+
endTime: "14:00",
|
|
48
|
+
allDay: false,
|
|
49
|
+
calendarId: "c1",
|
|
50
|
+
location: "",
|
|
51
|
+
guests: "",
|
|
52
|
+
notes: "",
|
|
53
|
+
repeat: "",
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
function Live({ startExpanded }: { startExpanded: boolean }) {
|
|
57
|
+
const [draft, setDraft] = useState(seed);
|
|
58
|
+
const [expanded, setExpanded] = useState(startExpanded);
|
|
59
|
+
return (
|
|
60
|
+
<div className="max-w-sm rounded-lg border border-line bg-surface-raised p-4">
|
|
61
|
+
<EventEditor
|
|
62
|
+
draft={draft}
|
|
63
|
+
onChange={setDraft}
|
|
64
|
+
calendars={calendars}
|
|
65
|
+
expanded={expanded}
|
|
66
|
+
onToggleExpanded={() => setExpanded((open) => !open)}
|
|
67
|
+
onSave={() => {}}
|
|
68
|
+
onCancel={() => {}}
|
|
69
|
+
/>
|
|
70
|
+
</div>
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export const Folded: Story = { render: () => <Live startExpanded={false} /> };
|
|
75
|
+
|
|
76
|
+
/** Everything the folded form was hiding. */
|
|
77
|
+
export const Unfolded: Story = { render: () => <Live startExpanded /> };
|
|
78
|
+
|
|
79
|
+
/** The same form sized for a bottom sheet: every control a thumb target. */
|
|
80
|
+
export const Touch: Story = {
|
|
81
|
+
render: () => {
|
|
82
|
+
const [draft, setDraft] = useState(seed);
|
|
83
|
+
const [expanded, setExpanded] = useState(false);
|
|
84
|
+
return (
|
|
85
|
+
<div className="max-w-[390px] rounded-lg border border-line bg-surface-raised p-4">
|
|
86
|
+
<EventEditor
|
|
87
|
+
draft={draft}
|
|
88
|
+
onChange={setDraft}
|
|
89
|
+
calendars={calendars}
|
|
90
|
+
expanded={expanded}
|
|
91
|
+
onToggleExpanded={() => setExpanded((open) => !open)}
|
|
92
|
+
onSave={() => {}}
|
|
93
|
+
onCancel={() => {}}
|
|
94
|
+
touch
|
|
95
|
+
/>
|
|
96
|
+
</div>
|
|
97
|
+
);
|
|
98
|
+
},
|
|
99
|
+
};
|