@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
package/package.json
CHANGED
|
@@ -14,6 +14,10 @@ const render = (overrides: Partial<AppShellSlottedProps> = {}) =>
|
|
|
14
14
|
}),
|
|
15
15
|
);
|
|
16
16
|
|
|
17
|
+
/** The percentage react-resizable-panels gave the list panel. */
|
|
18
|
+
const listSize = (html: string) =>
|
|
19
|
+
html.match(/data-panel-id="list" data-panel-size="([\d.]+)"/)?.[1];
|
|
20
|
+
|
|
17
21
|
describe("AppShellSlotted slot rendering", () => {
|
|
18
22
|
it("renders the list slot unconditionally", () => {
|
|
19
23
|
const html = render({ initialWidth: 800 });
|
|
@@ -80,6 +84,85 @@ describe("AppShellSlotted slot rendering", () => {
|
|
|
80
84
|
);
|
|
81
85
|
});
|
|
82
86
|
|
|
87
|
+
it("splits the list and reading panes 33/67 by default", () => {
|
|
88
|
+
assert.equal(
|
|
89
|
+
listSize(
|
|
90
|
+
render({
|
|
91
|
+
initialWidth: 1100,
|
|
92
|
+
reading: createElement(
|
|
93
|
+
"div",
|
|
94
|
+
{ "data-testid": "reading" },
|
|
95
|
+
"Reading",
|
|
96
|
+
),
|
|
97
|
+
}),
|
|
98
|
+
),
|
|
99
|
+
"33.0",
|
|
100
|
+
"the list keeps its standard third",
|
|
101
|
+
);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("widens the list default at compact density", () => {
|
|
105
|
+
assert.equal(
|
|
106
|
+
listSize(
|
|
107
|
+
render({
|
|
108
|
+
initialWidth: 1100,
|
|
109
|
+
density: "compact",
|
|
110
|
+
reading: createElement(
|
|
111
|
+
"div",
|
|
112
|
+
{ "data-testid": "reading" },
|
|
113
|
+
"Reading",
|
|
114
|
+
),
|
|
115
|
+
}),
|
|
116
|
+
),
|
|
117
|
+
"43.0",
|
|
118
|
+
"denser rows read better with more room",
|
|
119
|
+
);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("gives the list the larger share when the bias is on it", () => {
|
|
123
|
+
assert.equal(
|
|
124
|
+
listSize(
|
|
125
|
+
render({
|
|
126
|
+
initialWidth: 1100,
|
|
127
|
+
listBias: "list",
|
|
128
|
+
reading: createElement(
|
|
129
|
+
"div",
|
|
130
|
+
{ "data-testid": "reading" },
|
|
131
|
+
"Reading",
|
|
132
|
+
),
|
|
133
|
+
}),
|
|
134
|
+
),
|
|
135
|
+
"62.0",
|
|
136
|
+
"a list that is the work keeps the width",
|
|
137
|
+
);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("gives the list the smaller share when the bias is on the detail", () => {
|
|
141
|
+
assert.equal(
|
|
142
|
+
listSize(
|
|
143
|
+
render({
|
|
144
|
+
initialWidth: 1100,
|
|
145
|
+
listBias: "detail",
|
|
146
|
+
reading: createElement(
|
|
147
|
+
"div",
|
|
148
|
+
{ "data-testid": "reading" },
|
|
149
|
+
"Reading",
|
|
150
|
+
),
|
|
151
|
+
}),
|
|
152
|
+
),
|
|
153
|
+
"26.0",
|
|
154
|
+
"a spine of a list gets a spine's width",
|
|
155
|
+
);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("ignores the bias when there is no reading pane to split with", () => {
|
|
159
|
+
assert.equal(
|
|
160
|
+
listSize(render({ initialWidth: 1100, listBias: "list" })),
|
|
161
|
+
"100.0",
|
|
162
|
+
"one pane is the whole width at any bias",
|
|
163
|
+
);
|
|
164
|
+
});
|
|
165
|
+
|
|
83
166
|
it("renders the nav as a pane at/above 1024px (no folders trigger)", () => {
|
|
84
167
|
const html = render({ initialWidth: 1100 });
|
|
85
168
|
assert.match(html, /data-testid="nav"/, "nav is in the pane");
|
|
@@ -54,6 +54,14 @@ export interface AppShellSlottedProps {
|
|
|
54
54
|
* standard split. Only affects the two-pane (list + reading) default size.
|
|
55
55
|
*/
|
|
56
56
|
density?: "comfortable" | "compact";
|
|
57
|
+
/**
|
|
58
|
+
* Which pane the two-pane split favours. "balanced" (default) is the mail
|
|
59
|
+
* arrangement, where the list is an index and the reading pane is the work.
|
|
60
|
+
* "list" is for a list pane that is itself the work — a calendar grid needs
|
|
61
|
+
* the width a week of columns costs, and cannot give it up when a detail
|
|
62
|
+
* opens. "detail" goes the other way for a list that is only a spine.
|
|
63
|
+
*/
|
|
64
|
+
listBias?: "list" | "balanced" | "detail";
|
|
57
65
|
/**
|
|
58
66
|
* Header rendered only on narrow (< 1024px) widths — the mobile top bar.
|
|
59
67
|
* The web-client injects the app-specific bar (hamburger / title / search).
|
|
@@ -123,6 +131,16 @@ export interface AppShellLayoutContext {
|
|
|
123
131
|
|
|
124
132
|
const AppShellLayoutCtx = createContext<AppShellLayoutContext | null>(null);
|
|
125
133
|
|
|
134
|
+
/** The list panel's share of the two-pane group, and how far it may be dragged. */
|
|
135
|
+
function listShare(
|
|
136
|
+
bias: NonNullable<AppShellSlottedProps["listBias"]>,
|
|
137
|
+
density: NonNullable<AppShellSlottedProps["density"]>,
|
|
138
|
+
): { size: number; max: number } {
|
|
139
|
+
if (bias === "list") return { size: 62, max: 78 };
|
|
140
|
+
if (bias === "detail") return { size: 26, max: 45 };
|
|
141
|
+
return { size: density === "compact" ? 43 : 33, max: 58 };
|
|
142
|
+
}
|
|
143
|
+
|
|
126
144
|
/**
|
|
127
145
|
* Read the enclosing `AppShellSlotted`'s pane layout.
|
|
128
146
|
* Returns null outside of an `AppShellSlotted` (tests / Storybook).
|
|
@@ -143,6 +161,7 @@ export function AppShellSlotted({
|
|
|
143
161
|
intelligenceOpen = true,
|
|
144
162
|
hasThread = true,
|
|
145
163
|
density = "comfortable",
|
|
164
|
+
listBias = "balanced",
|
|
146
165
|
header,
|
|
147
166
|
topBar,
|
|
148
167
|
overlay,
|
|
@@ -200,14 +219,23 @@ export function AppShellSlotted({
|
|
|
200
219
|
|
|
201
220
|
/* Sizes are percentages of the group they sit in. This group excludes the nav
|
|
202
221
|
column, so they are shares of the remaining ~83%, not of the whole shell. */
|
|
222
|
+
const share = listShare(listBias, density);
|
|
203
223
|
const contentPanes = (
|
|
204
|
-
<ResizablePanelGroup
|
|
224
|
+
<ResizablePanelGroup
|
|
225
|
+
direction="horizontal"
|
|
226
|
+
className="min-h-0 flex-1"
|
|
227
|
+
/* A group whose panels come and go under it throws "previous layout not
|
|
228
|
+
found for panel index -1", and a panel already registered keeps the
|
|
229
|
+
layout it was given rather than the default it is handed next. Keying
|
|
230
|
+
on the set makes each arrangement its own group. */
|
|
231
|
+
key={showReadingPane ? "list-reading" : "list-only"}
|
|
232
|
+
>
|
|
205
233
|
<ResizablePanel
|
|
206
234
|
id="list"
|
|
207
235
|
order={1}
|
|
208
|
-
defaultSize={showReadingPane ?
|
|
236
|
+
defaultSize={showReadingPane ? share.size : 100}
|
|
209
237
|
minSize={22}
|
|
210
|
-
maxSize={showReadingPane ?
|
|
238
|
+
maxSize={showReadingPane ? share.max : 100}
|
|
211
239
|
className="min-w-0"
|
|
212
240
|
>
|
|
213
241
|
{list}
|
|
@@ -0,0 +1,73 @@
|
|
|
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 { AttendeeList, AttendeeRow, RsvpBadge } from "./attendee-row.js";
|
|
6
|
+
import type { CalendarAttendee } from "./calendar-types.js";
|
|
7
|
+
|
|
8
|
+
const attendees: CalendarAttendee[] = [
|
|
9
|
+
{
|
|
10
|
+
name: "Priya Natarajan",
|
|
11
|
+
email: "priya@northwind.example",
|
|
12
|
+
rsvp: "accepted",
|
|
13
|
+
role: "organizer",
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
name: "Dana Okafor",
|
|
17
|
+
email: "dana@northwind.example",
|
|
18
|
+
rsvp: "tentative",
|
|
19
|
+
role: "attendee",
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
name: "Sven Larsen",
|
|
23
|
+
email: "sven@northwind.example",
|
|
24
|
+
rsvp: "declined",
|
|
25
|
+
role: "attendee",
|
|
26
|
+
},
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
describe("RsvpBadge", () => {
|
|
30
|
+
it("says the reply in words, not colour alone", () => {
|
|
31
|
+
assert.match(
|
|
32
|
+
renderToString(createElement(RsvpBadge, { rsvp: "noReply" })),
|
|
33
|
+
/No reply/,
|
|
34
|
+
);
|
|
35
|
+
assert.match(
|
|
36
|
+
renderToString(createElement(RsvpBadge, { rsvp: "declined" })),
|
|
37
|
+
/Not coming/,
|
|
38
|
+
);
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
describe("AttendeeRow", () => {
|
|
43
|
+
it("names the organiser", () => {
|
|
44
|
+
const html = renderToString(
|
|
45
|
+
createElement(AttendeeRow, { attendee: attendees[0] }),
|
|
46
|
+
);
|
|
47
|
+
assert.match(html, /Organiser/);
|
|
48
|
+
assert.match(html, /Priya Natarajan/);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("leaves a plain attendee unlabelled", () => {
|
|
52
|
+
const html = renderToString(
|
|
53
|
+
createElement(AttendeeRow, { attendee: attendees[1] }),
|
|
54
|
+
);
|
|
55
|
+
assert.doesNotMatch(html, /Organiser/);
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
describe("AttendeeList", () => {
|
|
60
|
+
it("tallies the replies above the list", () => {
|
|
61
|
+
const html = renderToString(createElement(AttendeeList, { attendees }));
|
|
62
|
+
assert.match(html, /3 guests/);
|
|
63
|
+
assert.match(html, /1 coming/);
|
|
64
|
+
assert.match(html, /1 not coming/);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("renders nothing when nobody is invited", () => {
|
|
68
|
+
assert.equal(
|
|
69
|
+
renderToString(createElement(AttendeeList, { attendees: [] })),
|
|
70
|
+
"",
|
|
71
|
+
);
|
|
72
|
+
});
|
|
73
|
+
});
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
2
|
+
import { AttendeeList, RsvpBadge } from "./attendee-row.js";
|
|
3
|
+
import type { CalendarAttendee } from "./calendar-types.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Who is coming, and whether they said so. The reply is words and a mark, never
|
|
7
|
+
* a colour on its own.
|
|
8
|
+
*/
|
|
9
|
+
const meta: Meta = {
|
|
10
|
+
title: "Calendar/Attendees",
|
|
11
|
+
parameters: { layout: "padded" },
|
|
12
|
+
};
|
|
13
|
+
export default meta;
|
|
14
|
+
|
|
15
|
+
type Story = StoryObj;
|
|
16
|
+
|
|
17
|
+
const attendees: CalendarAttendee[] = [
|
|
18
|
+
{
|
|
19
|
+
name: "Priya Natarajan",
|
|
20
|
+
email: "priya@northwind.example",
|
|
21
|
+
rsvp: "accepted",
|
|
22
|
+
role: "organizer",
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
name: "Marcus Webb",
|
|
26
|
+
email: "marcus@northwind.example",
|
|
27
|
+
rsvp: "accepted",
|
|
28
|
+
role: "attendee",
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
name: "Dana Okafor",
|
|
32
|
+
email: "dana@northwind.example",
|
|
33
|
+
rsvp: "tentative",
|
|
34
|
+
role: "attendee",
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
name: "Aisha Khan",
|
|
38
|
+
email: "aisha@northwind.example",
|
|
39
|
+
rsvp: "noReply",
|
|
40
|
+
role: "attendee",
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
name: "Sven Larsen",
|
|
44
|
+
email: "sven@northwind.example",
|
|
45
|
+
rsvp: "declined",
|
|
46
|
+
role: "attendee",
|
|
47
|
+
},
|
|
48
|
+
];
|
|
49
|
+
|
|
50
|
+
/** A full guest list, with the tally read off it rather than stated twice. */
|
|
51
|
+
export const List: Story = {
|
|
52
|
+
render: () => (
|
|
53
|
+
<div className="max-w-sm rounded-lg border border-line bg-surface p-3">
|
|
54
|
+
<AttendeeList attendees={attendees} />
|
|
55
|
+
</div>
|
|
56
|
+
),
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
export const EveryReply: Story = {
|
|
60
|
+
render: () => (
|
|
61
|
+
<div className="flex gap-4">
|
|
62
|
+
<RsvpBadge rsvp="accepted" />
|
|
63
|
+
<RsvpBadge rsvp="tentative" />
|
|
64
|
+
<RsvpBadge rsvp="declined" />
|
|
65
|
+
<RsvpBadge rsvp="noReply" />
|
|
66
|
+
</div>
|
|
67
|
+
),
|
|
68
|
+
};
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { Check, Clock, Minus, X } from "lucide-react";
|
|
2
|
+
import { cn } from "../lib/cn.js";
|
|
3
|
+
import { Avatar } from "./avatar.js";
|
|
4
|
+
import type { CalendarAttendee, RsvpState } from "./calendar-types.js";
|
|
5
|
+
|
|
6
|
+
const rsvpLabels: Record<RsvpState, string> = {
|
|
7
|
+
accepted: "Coming",
|
|
8
|
+
tentative: "Maybe",
|
|
9
|
+
declined: "Not coming",
|
|
10
|
+
noReply: "No reply",
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
const rsvpTone: Record<RsvpState, string> = {
|
|
14
|
+
accepted: "text-positive",
|
|
15
|
+
tentative: "text-warning",
|
|
16
|
+
declined: "text-danger",
|
|
17
|
+
noReply: "text-fg-subtle",
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const rsvpIcons: Record<RsvpState, typeof Check> = {
|
|
21
|
+
accepted: Check,
|
|
22
|
+
tentative: Clock,
|
|
23
|
+
declined: X,
|
|
24
|
+
noReply: Minus,
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export interface RsvpBadgeProps {
|
|
28
|
+
rsvp: RsvpState;
|
|
29
|
+
className?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** The reply, in words and a mark — never colour alone. */
|
|
33
|
+
export function RsvpBadge({ rsvp, className }: RsvpBadgeProps) {
|
|
34
|
+
const Icon = rsvpIcons[rsvp];
|
|
35
|
+
return (
|
|
36
|
+
<span
|
|
37
|
+
className={cn(
|
|
38
|
+
"inline-flex items-center gap-1 text-2xs font-medium",
|
|
39
|
+
rsvpTone[rsvp],
|
|
40
|
+
className,
|
|
41
|
+
)}
|
|
42
|
+
>
|
|
43
|
+
<Icon className="size-3" />
|
|
44
|
+
{rsvpLabels[rsvp]}
|
|
45
|
+
</span>
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface AttendeeRowProps {
|
|
50
|
+
attendee: CalendarAttendee;
|
|
51
|
+
className?: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function AttendeeRow({ attendee, className }: AttendeeRowProps) {
|
|
55
|
+
return (
|
|
56
|
+
<div className={cn("flex min-h-9 items-center gap-2.5", className)}>
|
|
57
|
+
<Avatar name={attendee.name} email={attendee.email} size="sm" />
|
|
58
|
+
<span className="min-w-0 flex-1">
|
|
59
|
+
<span className="block truncate text-sm text-fg">{attendee.name}</span>
|
|
60
|
+
{attendee.role === "organizer" && (
|
|
61
|
+
<span className="block text-2xs text-fg-subtle">Organiser</span>
|
|
62
|
+
)}
|
|
63
|
+
</span>
|
|
64
|
+
<RsvpBadge rsvp={attendee.rsvp} />
|
|
65
|
+
</div>
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface AttendeeListProps {
|
|
70
|
+
attendees: CalendarAttendee[];
|
|
71
|
+
className?: string;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** The full guest list with a one-line tally above it. */
|
|
75
|
+
export function AttendeeList({ attendees, className }: AttendeeListProps) {
|
|
76
|
+
if (attendees.length === 0) return null;
|
|
77
|
+
const tally = (["accepted", "tentative", "declined", "noReply"] as const)
|
|
78
|
+
.map((state) => ({
|
|
79
|
+
state,
|
|
80
|
+
count: attendees.filter((a) => a.rsvp === state).length,
|
|
81
|
+
}))
|
|
82
|
+
.filter((entry) => entry.count > 0)
|
|
83
|
+
.map((entry) => `${entry.count} ${rsvpLabels[entry.state].toLowerCase()}`)
|
|
84
|
+
.join(" · ");
|
|
85
|
+
|
|
86
|
+
return (
|
|
87
|
+
<div className={cn("flex flex-col", className)}>
|
|
88
|
+
<p className="pb-1 text-2xs uppercase tracking-wider text-fg-subtle">
|
|
89
|
+
{`${attendees.length} guests · ${tally}`}
|
|
90
|
+
</p>
|
|
91
|
+
{attendees.map((attendee) => (
|
|
92
|
+
<AttendeeRow key={attendee.email} attendee={attendee} />
|
|
93
|
+
))}
|
|
94
|
+
</div>
|
|
95
|
+
);
|
|
96
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
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 {
|
|
6
|
+
CalendarEventChip,
|
|
7
|
+
type CalendarEventChipProps,
|
|
8
|
+
} from "./calendar-event-chip.js";
|
|
9
|
+
|
|
10
|
+
const base: CalendarEventChipProps = {
|
|
11
|
+
title: "Q3 roadmap review",
|
|
12
|
+
timeText: "10:00",
|
|
13
|
+
color: "cal-1",
|
|
14
|
+
layout: "row",
|
|
15
|
+
density: "comfortable",
|
|
16
|
+
rsvp: "accepted",
|
|
17
|
+
status: "confirmed",
|
|
18
|
+
hasThread: false,
|
|
19
|
+
isRecurring: false,
|
|
20
|
+
zoneCertainty: "local",
|
|
21
|
+
selected: false,
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const render = (props: Partial<CalendarEventChipProps>) =>
|
|
25
|
+
renderToString(createElement(CalendarEventChip, { ...base, ...props }));
|
|
26
|
+
|
|
27
|
+
describe("CalendarEventChip", () => {
|
|
28
|
+
it("carries the calendar's hue and nothing else coloured", () => {
|
|
29
|
+
const html = render({ color: "cal-3" });
|
|
30
|
+
assert.match(html, /bg-cal-3-soft/);
|
|
31
|
+
assert.match(html, /text-cal-3/);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("renders the time ahead of the title", () => {
|
|
35
|
+
const html = render({});
|
|
36
|
+
assert.match(html, /10:00/);
|
|
37
|
+
assert.match(html, /Q3 roadmap review/);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("omits the time for an all-day entry", () => {
|
|
41
|
+
const html = render({ timeText: "" });
|
|
42
|
+
assert.doesNotMatch(html, /tabular-nums/);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("strikes a declined event rather than recolouring it", () => {
|
|
46
|
+
const html = render({ rsvp: "declined" });
|
|
47
|
+
assert.match(html, /line-through/);
|
|
48
|
+
assert.match(html, /bg-cal-1-soft/);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("dashes the border of a tentative event", () => {
|
|
52
|
+
const html = render({ status: "tentative" });
|
|
53
|
+
assert.match(html, /border-dashed/);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("marks an event that came from mail", () => {
|
|
57
|
+
assert.match(render({ hasThread: true }), /From mail/);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("marks a repeating event", () => {
|
|
61
|
+
assert.match(render({ isRecurring: true }), /Repeats/);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("flags a zone it could not determine", () => {
|
|
65
|
+
assert.match(render({ zoneCertainty: "ambiguous" }), /Unclear zone/);
|
|
66
|
+
});
|
|
67
|
+
});
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
2
|
+
import { CalendarEventChip } from "./calendar-event-chip.js";
|
|
3
|
+
import { calendarColorIds } from "./calendar-types.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The event as it appears wherever the event's element is ours: an agenda, a
|
|
7
|
+
* picker, a day column we draw ourselves. Colour says which calendar; shape and
|
|
8
|
+
* mark say everything else, so an event never depends on hue alone to be read.
|
|
9
|
+
*
|
|
10
|
+
* Option A's grid is FullCalendar, which renders the element itself and accepts
|
|
11
|
+
* only a class string and the content inside it, so that one surface restates
|
|
12
|
+
* this shell rather than mounting the component. The two are held to the same
|
|
13
|
+
* values — hue, the dashed box for a provisional event, the dimming for a
|
|
14
|
+
* declined one, the mark size. What the grid cannot take from here is the
|
|
15
|
+
* element: no `aria-pressed`, no focus ring of ours, and a column too short for
|
|
16
|
+
* the second line this chip puts its marks on.
|
|
17
|
+
*/
|
|
18
|
+
const meta: Meta<typeof CalendarEventChip> = {
|
|
19
|
+
title: "Calendar/Event chip",
|
|
20
|
+
component: CalendarEventChip,
|
|
21
|
+
parameters: { layout: "padded" },
|
|
22
|
+
};
|
|
23
|
+
export default meta;
|
|
24
|
+
|
|
25
|
+
type Story = StoryObj<typeof CalendarEventChip>;
|
|
26
|
+
|
|
27
|
+
const base = {
|
|
28
|
+
title: "Q3 roadmap review",
|
|
29
|
+
timeText: "10:00",
|
|
30
|
+
color: "cal-1",
|
|
31
|
+
layout: "row",
|
|
32
|
+
density: "comfortable",
|
|
33
|
+
rsvp: "accepted",
|
|
34
|
+
status: "confirmed",
|
|
35
|
+
hasThread: false,
|
|
36
|
+
isRecurring: false,
|
|
37
|
+
zoneCertainty: "local",
|
|
38
|
+
selected: false,
|
|
39
|
+
} as const;
|
|
40
|
+
|
|
41
|
+
/** The six calendar hues side by side, which is the only test that matters. */
|
|
42
|
+
export const EveryCalendarColour: Story = {
|
|
43
|
+
render: () => (
|
|
44
|
+
<div className="flex max-w-sm flex-col gap-1">
|
|
45
|
+
{calendarColorIds.map((color, index) => (
|
|
46
|
+
<CalendarEventChip
|
|
47
|
+
key={color}
|
|
48
|
+
{...base}
|
|
49
|
+
color={color}
|
|
50
|
+
title={`Calendar ${index + 1}`}
|
|
51
|
+
/>
|
|
52
|
+
))}
|
|
53
|
+
</div>
|
|
54
|
+
),
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
/** Every state the chip carries, in the order it degrades. */
|
|
58
|
+
export const States: Story = {
|
|
59
|
+
render: () => (
|
|
60
|
+
<div className="flex max-w-sm flex-col gap-1">
|
|
61
|
+
<CalendarEventChip {...base} />
|
|
62
|
+
<CalendarEventChip {...base} title="Staff screen" status="tentative" />
|
|
63
|
+
<CalendarEventChip {...base} title="Vendor call" rsvp="declined" />
|
|
64
|
+
<CalendarEventChip
|
|
65
|
+
{...base}
|
|
66
|
+
title="Standup"
|
|
67
|
+
isRecurring
|
|
68
|
+
hasThread={false}
|
|
69
|
+
/>
|
|
70
|
+
<CalendarEventChip {...base} title="Incident review" hasThread />
|
|
71
|
+
<CalendarEventChip
|
|
72
|
+
{...base}
|
|
73
|
+
title="Offsite dinner"
|
|
74
|
+
timeText="19:00"
|
|
75
|
+
zoneCertainty="ambiguous"
|
|
76
|
+
/>
|
|
77
|
+
<CalendarEventChip {...base} title="Demo" selected />
|
|
78
|
+
</div>
|
|
79
|
+
),
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
/** In a time grid the chip fills its slot; the rail keeps the hue readable. */
|
|
83
|
+
export const ColumnLayout: Story = {
|
|
84
|
+
render: () => (
|
|
85
|
+
<div className="flex h-40 gap-1">
|
|
86
|
+
<CalendarEventChip {...base} layout="column" color="cal-1" />
|
|
87
|
+
<CalendarEventChip
|
|
88
|
+
{...base}
|
|
89
|
+
layout="column"
|
|
90
|
+
color="cal-3"
|
|
91
|
+
title="Design crit"
|
|
92
|
+
timeText="10:15"
|
|
93
|
+
/>
|
|
94
|
+
<CalendarEventChip
|
|
95
|
+
{...base}
|
|
96
|
+
layout="column"
|
|
97
|
+
color="cal-4"
|
|
98
|
+
title="Vendor call"
|
|
99
|
+
timeText="10:45"
|
|
100
|
+
rsvp="declined"
|
|
101
|
+
/>
|
|
102
|
+
</div>
|
|
103
|
+
),
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
/** Tighter density drops the chip to the smallest size that still reads. */
|
|
107
|
+
export const Compact: Story = {
|
|
108
|
+
render: () => (
|
|
109
|
+
<div className="flex max-w-sm flex-col gap-0.5">
|
|
110
|
+
<CalendarEventChip {...base} density="compact" />
|
|
111
|
+
<CalendarEventChip
|
|
112
|
+
{...base}
|
|
113
|
+
density="compact"
|
|
114
|
+
color="cal-2"
|
|
115
|
+
title="Lunch walk"
|
|
116
|
+
timeText="12:30"
|
|
117
|
+
hasThread
|
|
118
|
+
/>
|
|
119
|
+
<CalendarEventChip
|
|
120
|
+
{...base}
|
|
121
|
+
density="compact"
|
|
122
|
+
color="cal-5"
|
|
123
|
+
title="Modular jam"
|
|
124
|
+
timeText="20:00"
|
|
125
|
+
/>
|
|
126
|
+
</div>
|
|
127
|
+
),
|
|
128
|
+
};
|