@remit/web-client 0.0.230 → 0.0.232
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/calendar/CalendarComposePane.stories.tsx +115 -0
- package/src/components/calendar/CalendarComposePane.tsx +94 -0
- package/src/components/calendar/CalendarComposeSeed.tsx +35 -0
- package/src/components/calendar/CalendarEventPane.stories.tsx +110 -0
- package/src/components/calendar/CalendarEventPane.tsx +90 -0
- package/src/components/calendar/CalendarShell.tsx +83 -0
- package/src/components/calendar/CalendarViewPlaceholder.tsx +37 -0
- package/src/components/calendar/CalendarWorkspace.stories.tsx +219 -0
- package/src/components/calendar/CalendarWorkspace.tsx +192 -0
- package/src/components/calendar/OpenCalendarEvent.tsx +221 -0
- package/src/components/calendar/WriteCalendarEvent.tsx +94 -0
- package/src/components/calendar/calendar-workspace.render.test.ts +193 -0
- package/src/components/calendar/calendar-write-flow.render.test.ts +356 -0
- package/src/components/mail/MailSidebarAdapter.tsx +17 -0
- package/src/hooks/calendar/calendar-reads.render.test.ts +265 -0
- package/src/hooks/calendar/calendar-writes.render.test.ts +305 -0
- package/src/hooks/calendar/draft.test.ts +241 -0
- package/src/hooks/calendar/draft.ts +168 -0
- package/src/hooks/calendar/index.ts +59 -0
- package/src/hooks/calendar/instance.ts +136 -0
- package/src/hooks/calendar/recurrence-rule.test.ts +92 -0
- package/src/hooks/calendar/recurrence-rule.ts +133 -0
- package/src/hooks/calendar/resource.test.ts +98 -0
- package/src/hooks/calendar/resource.ts +74 -0
- package/src/hooks/calendar/useCalendarEvent.ts +46 -0
- package/src/hooks/calendar/useCalendarEvents.ts +124 -0
- package/src/hooks/calendar/useCalendarWrites.ts +174 -0
- package/src/hooks/calendar/useCalendars.ts +43 -0
- package/src/hooks/calendar/window.test.ts +148 -0
- package/src/hooks/calendar/window.ts +163 -0
- package/src/hooks/useCalendarData.ts +120 -0
- package/src/lib/calendar-density.ts +34 -0
- package/src/lib/calendar-format.ts +29 -0
- package/src/lib/calendar-route.test.ts +210 -0
- package/src/lib/calendar-route.ts +187 -0
- package/src/lib/search-params.test.ts +77 -0
- package/src/lib/search-params.ts +59 -0
- package/src/routeTree.gen.ts +199 -0
- package/src/router.tsx +5 -0
- package/src/routes/calendar/$view.$date/$calendarObjectId/$recurrenceId.tsx +26 -0
- package/src/routes/calendar/$view.$date/$calendarObjectId/index.tsx +18 -0
- package/src/routes/calendar/$view.$date/$calendarObjectId.tsx +15 -0
- package/src/routes/calendar/$view.$date/index.tsx +25 -0
- package/src/routes/calendar/$view.$date/new.tsx +20 -0
- package/src/routes/calendar/$view.$date.tsx +128 -0
- package/src/routes/calendar/-calendar-tree.test.ts +267 -0
- package/src/routes/calendar/index.tsx +27 -0
- package/src/routes/calendar.tsx +98 -0
- package/src/routing/calendar-fragment.test.ts +66 -0
- package/src/routing/calendar.ts +150 -0
- package/src/routing/fragment.ts +19 -0
- package/src/routing/index.ts +10 -0
- package/src/routing/nav-selection.ts +2 -0
- package/src/test-support/http.ts +8 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@remit/web-client",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.232",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Remit web client, published as composable primitives — the app shell, auth shells, and runtime config. A distributor imports what it composes and bundles it.",
|
|
6
6
|
"exports": {
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import type { CalendarDescriptor, EventDraft } from "@remit/ui";
|
|
2
|
+
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
3
|
+
import { useState } from "react";
|
|
4
|
+
import { CalendarComposePane } from "./CalendarComposePane";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* `/calendar/{view}/{date}/new`, and the same form reopened over an event being
|
|
8
|
+
* edited.
|
|
9
|
+
*
|
|
10
|
+
* It holds no draft of its own, so a story drives it with local state and gets
|
|
11
|
+
* the surface the app renders. What the stories are for is what the markup
|
|
12
|
+
* cannot answer: whether a refusal reads as something to fix rather than as
|
|
13
|
+
* something broken, and whether an edit scoped to one occurrence makes clear
|
|
14
|
+
* that the rule is not up for changing.
|
|
15
|
+
*/
|
|
16
|
+
const calendars: CalendarDescriptor[] = [
|
|
17
|
+
{
|
|
18
|
+
id: "cal_work",
|
|
19
|
+
accountId: "acct_work",
|
|
20
|
+
accountLabel: "Work",
|
|
21
|
+
name: "Northwind",
|
|
22
|
+
color: "cal-1",
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
id: "cal_home",
|
|
26
|
+
accountId: "acct_work",
|
|
27
|
+
accountLabel: "Work",
|
|
28
|
+
name: "Personal",
|
|
29
|
+
color: "cal-4",
|
|
30
|
+
},
|
|
31
|
+
];
|
|
32
|
+
|
|
33
|
+
const draft: EventDraft = {
|
|
34
|
+
title: "Roadmap review",
|
|
35
|
+
date: "2026-06-10",
|
|
36
|
+
startTime: "10:00",
|
|
37
|
+
endTime: "11:30",
|
|
38
|
+
allDay: false,
|
|
39
|
+
calendarId: "cal_work",
|
|
40
|
+
location: "Room Zuid",
|
|
41
|
+
guests: "",
|
|
42
|
+
notes: "",
|
|
43
|
+
repeat: "",
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const meta: Meta<typeof CalendarComposePane> = {
|
|
47
|
+
title: "App/Calendar/Compose pane",
|
|
48
|
+
component: CalendarComposePane,
|
|
49
|
+
parameters: { layout: "fullscreen" },
|
|
50
|
+
args: {
|
|
51
|
+
title: "New event",
|
|
52
|
+
subtitle: "2026-06-10",
|
|
53
|
+
calendars,
|
|
54
|
+
draft,
|
|
55
|
+
problem: "",
|
|
56
|
+
saveLabel: "Add",
|
|
57
|
+
isSaving: false,
|
|
58
|
+
onSave: () => {},
|
|
59
|
+
onCancel: () => {},
|
|
60
|
+
},
|
|
61
|
+
render: function Render(args) {
|
|
62
|
+
const [current, setCurrent] = useState(args.draft);
|
|
63
|
+
return (
|
|
64
|
+
<div className="h-dvh max-w-2xl border-l border-line bg-canvas">
|
|
65
|
+
<CalendarComposePane {...args} draft={current} onChange={setCurrent} />
|
|
66
|
+
</div>
|
|
67
|
+
);
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
export default meta;
|
|
71
|
+
|
|
72
|
+
type Story = StoryObj<typeof CalendarComposePane>;
|
|
73
|
+
|
|
74
|
+
export const NewEvent: Story = {};
|
|
75
|
+
|
|
76
|
+
/** A refusal says what was wrong and what to do, where the reader is looking. */
|
|
77
|
+
export const Refused: Story = {
|
|
78
|
+
args: {
|
|
79
|
+
draft: { ...draft, title: "" },
|
|
80
|
+
problem: "Give the event a title before saving it.",
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
/** The write is out; the button says so rather than looking unpressed. */
|
|
85
|
+
export const Saving: Story = { args: { isSaving: true } };
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Editing one morning of a series. The rule belongs to the series, so it reads
|
|
89
|
+
* back rather than offering itself for change.
|
|
90
|
+
*/
|
|
91
|
+
export const EditingOneOccurrence: Story = {
|
|
92
|
+
args: {
|
|
93
|
+
title: "Edit event",
|
|
94
|
+
subtitle: "This occurrence only",
|
|
95
|
+
saveLabel: "Save",
|
|
96
|
+
repeatEditable: false,
|
|
97
|
+
draft: {
|
|
98
|
+
...draft,
|
|
99
|
+
title: "Standup",
|
|
100
|
+
startTime: "09:15",
|
|
101
|
+
endTime: "09:30",
|
|
102
|
+
repeat: "Every weekday, 09:15",
|
|
103
|
+
},
|
|
104
|
+
},
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
/** The version on screen was replaced elsewhere. Nothing is overwritten. */
|
|
108
|
+
export const Conflicted: Story = {
|
|
109
|
+
args: {
|
|
110
|
+
title: "Edit event",
|
|
111
|
+
saveLabel: "Save",
|
|
112
|
+
problem:
|
|
113
|
+
"This event changed somewhere else — over CalDAV, or in another tab. Nothing was saved. Close it and open it again to see the version that's stored now.",
|
|
114
|
+
},
|
|
115
|
+
};
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type CalendarDescriptor,
|
|
3
|
+
type EventDraft,
|
|
4
|
+
EventEditor,
|
|
5
|
+
EventEditorPane,
|
|
6
|
+
} from "@remit/ui";
|
|
7
|
+
import { AlertCircle } from "lucide-react";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Writing an event, and editing one.
|
|
11
|
+
*
|
|
12
|
+
* Presentational, like the pane it sits beside: it holds no draft of its own
|
|
13
|
+
* and saves nothing. The route owns the draft and the write, so this renders
|
|
14
|
+
* the same form whichever of the two the address named.
|
|
15
|
+
*
|
|
16
|
+
* Guests are left out because the calendar store has nowhere to put them yet,
|
|
17
|
+
* which is the kit's default. A field that takes names and drops them is worse
|
|
18
|
+
* than no field: the reader only finds out afterwards, from an event with
|
|
19
|
+
* nobody on it.
|
|
20
|
+
*/
|
|
21
|
+
export interface CalendarComposePaneProps {
|
|
22
|
+
/** What the pane is doing: "New event", "Edit event". */
|
|
23
|
+
title: string;
|
|
24
|
+
/** The one line of context the title cannot carry — which day, which series. */
|
|
25
|
+
subtitle?: string;
|
|
26
|
+
calendars: CalendarDescriptor[];
|
|
27
|
+
draft: EventDraft;
|
|
28
|
+
onChange: (draft: EventDraft) => void;
|
|
29
|
+
/**
|
|
30
|
+
* Why the last save did not happen, in the words the reader needs: what was
|
|
31
|
+
* refused and what to do about it. Empty when nothing has been refused.
|
|
32
|
+
*/
|
|
33
|
+
problem: string;
|
|
34
|
+
saveLabel: string;
|
|
35
|
+
isSaving: boolean;
|
|
36
|
+
/**
|
|
37
|
+
* The rule belongs to the series rather than to one morning of it, so an
|
|
38
|
+
* edit scoped to a single occurrence reads it back instead of offering it.
|
|
39
|
+
*/
|
|
40
|
+
repeatEditable?: boolean;
|
|
41
|
+
/**
|
|
42
|
+
* Whether the event can still be sent to a different calendar. Writing one
|
|
43
|
+
* chooses; editing one cannot, because the collection a resource lives in is
|
|
44
|
+
* part of its address and the patch has no field for it.
|
|
45
|
+
*/
|
|
46
|
+
calendarEditable?: boolean;
|
|
47
|
+
onSave: () => void;
|
|
48
|
+
onCancel: () => void;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function CalendarComposePane({
|
|
52
|
+
title,
|
|
53
|
+
subtitle,
|
|
54
|
+
calendars,
|
|
55
|
+
draft,
|
|
56
|
+
onChange,
|
|
57
|
+
problem,
|
|
58
|
+
saveLabel,
|
|
59
|
+
isSaving,
|
|
60
|
+
repeatEditable = true,
|
|
61
|
+
calendarEditable = true,
|
|
62
|
+
onSave,
|
|
63
|
+
onCancel,
|
|
64
|
+
}: CalendarComposePaneProps) {
|
|
65
|
+
return (
|
|
66
|
+
<EventEditorPane title={title} subtitle={subtitle} onClose={onCancel}>
|
|
67
|
+
<EventEditor
|
|
68
|
+
draft={draft}
|
|
69
|
+
onChange={onChange}
|
|
70
|
+
calendars={calendars}
|
|
71
|
+
layout="pane"
|
|
72
|
+
repeatEditable={repeatEditable}
|
|
73
|
+
calendarEditable={calendarEditable}
|
|
74
|
+
saveLabel={isSaving ? "Saving…" : saveLabel}
|
|
75
|
+
onSave={onSave}
|
|
76
|
+
onCancel={onCancel}
|
|
77
|
+
header={
|
|
78
|
+
problem === "" ? undefined : (
|
|
79
|
+
<div
|
|
80
|
+
role="alert"
|
|
81
|
+
className="flex items-start gap-2 rounded-md border border-danger/40 bg-danger-soft px-3 py-2 text-sm"
|
|
82
|
+
>
|
|
83
|
+
<AlertCircle
|
|
84
|
+
className="mt-0.5 size-4 shrink-0 text-danger"
|
|
85
|
+
aria-hidden="true"
|
|
86
|
+
/>
|
|
87
|
+
<p className="min-w-0 flex-1 break-words text-fg">{problem}</p>
|
|
88
|
+
</div>
|
|
89
|
+
)
|
|
90
|
+
}
|
|
91
|
+
/>
|
|
92
|
+
</EventEditorPane>
|
|
93
|
+
);
|
|
94
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { CalendarSlotPick } from "@remit/ui";
|
|
2
|
+
import { createContext, type ReactNode, useContext } from "react";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The slot a reader dragged out of the grid, handed to the composer the click
|
|
6
|
+
* opened.
|
|
7
|
+
*
|
|
8
|
+
* It is not in the address on purpose. What a half-written event says is not a
|
|
9
|
+
* fact about where the reader is, and a link to a composer pre-filled with
|
|
10
|
+
* somebody else's Tuesday afternoon is not an address worth being able to send.
|
|
11
|
+
* The composer opened without one starts on the day the calendar is showing.
|
|
12
|
+
*
|
|
13
|
+
* It lives in a module reached through the `@/` alias for the same reason
|
|
14
|
+
* `CalendarShell`'s context does: the generated route tree imports route files
|
|
15
|
+
* relatively, so a context declared inside one resolves to a second module
|
|
16
|
+
* instance and hands the consumer an empty default.
|
|
17
|
+
*/
|
|
18
|
+
const CalendarComposeSeedCtx = createContext<CalendarSlotPick | undefined>(
|
|
19
|
+
undefined,
|
|
20
|
+
);
|
|
21
|
+
|
|
22
|
+
export const CalendarComposeSeedProvider = ({
|
|
23
|
+
pick,
|
|
24
|
+
children,
|
|
25
|
+
}: {
|
|
26
|
+
pick: CalendarSlotPick | undefined;
|
|
27
|
+
children?: ReactNode;
|
|
28
|
+
}) => (
|
|
29
|
+
<CalendarComposeSeedCtx.Provider value={pick}>
|
|
30
|
+
{children}
|
|
31
|
+
</CalendarComposeSeedCtx.Provider>
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
export const useCalendarComposeSeed = (): CalendarSlotPick | undefined =>
|
|
35
|
+
useContext(CalendarComposeSeedCtx);
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import type { CalendarDescriptor, CalendarEventData } from "@remit/ui";
|
|
2
|
+
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
3
|
+
import { CalendarEventPane } from "./CalendarEventPane";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The calendar's reading pane, at the three states the routes below the view
|
|
7
|
+
* can put it in: the event, one occurrence of a repeating one, and an address
|
|
8
|
+
* naming an event the calendar does not have on screen.
|
|
9
|
+
*/
|
|
10
|
+
const calendar: CalendarDescriptor = {
|
|
11
|
+
id: "cal_work",
|
|
12
|
+
accountId: "acct_work",
|
|
13
|
+
accountLabel: "Work",
|
|
14
|
+
name: "Northwind",
|
|
15
|
+
color: "cal-1",
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const event: CalendarEventData = {
|
|
19
|
+
id: "evt_roadmap",
|
|
20
|
+
calendarId: calendar.id,
|
|
21
|
+
title: "Roadmap review",
|
|
22
|
+
start: "2026-06-10T10:00:00+02:00",
|
|
23
|
+
end: "2026-06-10T11:30:00+02:00",
|
|
24
|
+
allDay: false,
|
|
25
|
+
location: "Room Zuid",
|
|
26
|
+
notes: "Bring the staffing numbers.",
|
|
27
|
+
attendees: [
|
|
28
|
+
{
|
|
29
|
+
name: "Priya Natarajan",
|
|
30
|
+
email: "priya@northwind.example",
|
|
31
|
+
rsvp: "accepted",
|
|
32
|
+
role: "organizer",
|
|
33
|
+
},
|
|
34
|
+
],
|
|
35
|
+
myRsvp: "accepted",
|
|
36
|
+
threadId: "",
|
|
37
|
+
threadSubject: "",
|
|
38
|
+
timeZone: "Europe/Amsterdam",
|
|
39
|
+
zoneCertainty: "explicit",
|
|
40
|
+
recurrenceRule: "",
|
|
41
|
+
seriesId: "",
|
|
42
|
+
seriesException: false,
|
|
43
|
+
status: "confirmed",
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const meta: Meta<typeof CalendarEventPane> = {
|
|
47
|
+
title: "App/Calendar/Event pane",
|
|
48
|
+
component: CalendarEventPane,
|
|
49
|
+
parameters: { layout: "fullscreen" },
|
|
50
|
+
args: {
|
|
51
|
+
event,
|
|
52
|
+
calendar,
|
|
53
|
+
isOccurrence: false,
|
|
54
|
+
problem: "",
|
|
55
|
+
onEdit: () => {},
|
|
56
|
+
onDelete: () => {},
|
|
57
|
+
onClose: () => {},
|
|
58
|
+
},
|
|
59
|
+
render: (args) => (
|
|
60
|
+
<div className="h-dvh max-w-xl border-l border-line bg-canvas">
|
|
61
|
+
<CalendarEventPane {...args} />
|
|
62
|
+
</div>
|
|
63
|
+
),
|
|
64
|
+
};
|
|
65
|
+
export default meta;
|
|
66
|
+
|
|
67
|
+
type Story = StoryObj<typeof CalendarEventPane>;
|
|
68
|
+
|
|
69
|
+
export const Event: Story = {};
|
|
70
|
+
|
|
71
|
+
/** The series is what the address names; the occurrence is the segment below. */
|
|
72
|
+
export const Occurrence: Story = {
|
|
73
|
+
args: {
|
|
74
|
+
event: {
|
|
75
|
+
...event,
|
|
76
|
+
title: "Standup",
|
|
77
|
+
start: "2026-06-11T09:15:00+02:00",
|
|
78
|
+
end: "2026-06-11T09:30:00+02:00",
|
|
79
|
+
recurrenceRule: "Every weekday, 09:15",
|
|
80
|
+
seriesId: "ser_standup",
|
|
81
|
+
},
|
|
82
|
+
isOccurrence: true,
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
/** An old link, or a week the event is not in. Never a blank pane. */
|
|
87
|
+
export const NotOnThisWeek: Story = {
|
|
88
|
+
args: { event: undefined, calendar: undefined },
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* The event was replaced somewhere else — over CalDAV, or in another tab —
|
|
93
|
+
* between it being read and the write going out. Nothing was overwritten, and
|
|
94
|
+
* the pane says so where the reader is looking rather than anywhere else.
|
|
95
|
+
*/
|
|
96
|
+
export const ChangedElsewhere: Story = {
|
|
97
|
+
args: {
|
|
98
|
+
problem:
|
|
99
|
+
"This event changed somewhere else — over CalDAV, or in another tab. Nothing was saved. Close it and open it again to see the version that's stored now.",
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* The resource has not been read yet, so there is no version to write against.
|
|
105
|
+
* The controls stay off until there is: a write with no etag is one that can
|
|
106
|
+
* quietly overwrite somebody.
|
|
107
|
+
*/
|
|
108
|
+
export const NotWritableYet: Story = {
|
|
109
|
+
args: { onEdit: undefined, onDelete: undefined },
|
|
110
|
+
};
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import type { CalendarDescriptor, CalendarEventData } from "@remit/ui";
|
|
2
|
+
import { EventDetail } from "@remit/ui";
|
|
3
|
+
import { AlertCircle, CalendarDays } from "lucide-react";
|
|
4
|
+
import { formatEventWhen } from "@/lib/calendar-format";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The event the address has open.
|
|
8
|
+
*
|
|
9
|
+
* Presentational, like the workspace beside it: the route resolves the event
|
|
10
|
+
* and turns closing, editing and deleting into things it does, so this renders
|
|
11
|
+
* what it is handed and nothing else. Each control appears only when the route
|
|
12
|
+
* gave it somewhere to go — a control that leads nowhere is worse than none.
|
|
13
|
+
*/
|
|
14
|
+
export interface CalendarEventPaneProps {
|
|
15
|
+
/** Absent when the address names an event the calendar does not have. */
|
|
16
|
+
event: CalendarEventData | undefined;
|
|
17
|
+
calendar: CalendarDescriptor | undefined;
|
|
18
|
+
/** Whether the address names one occurrence rather than the series. */
|
|
19
|
+
isOccurrence: boolean;
|
|
20
|
+
/**
|
|
21
|
+
* A write that did not happen, stated where the reader is looking. Empty
|
|
22
|
+
* when the last one did.
|
|
23
|
+
*/
|
|
24
|
+
problem?: string;
|
|
25
|
+
onEdit?: () => void;
|
|
26
|
+
onDelete?: () => void;
|
|
27
|
+
onClose: () => void;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function CalendarEventPane({
|
|
31
|
+
event,
|
|
32
|
+
calendar,
|
|
33
|
+
isOccurrence,
|
|
34
|
+
problem = "",
|
|
35
|
+
onEdit,
|
|
36
|
+
onDelete,
|
|
37
|
+
onClose,
|
|
38
|
+
}: CalendarEventPaneProps) {
|
|
39
|
+
if (!event || !calendar) {
|
|
40
|
+
return (
|
|
41
|
+
<div className="flex h-full flex-col items-center justify-center gap-2 bg-surface p-8 text-center">
|
|
42
|
+
<CalendarDays className="size-8 text-fg-subtle" aria-hidden="true" />
|
|
43
|
+
<p className="text-sm font-medium text-fg">
|
|
44
|
+
That event isn't on this week
|
|
45
|
+
</p>
|
|
46
|
+
<p className="max-w-xs text-sm text-fg-muted">
|
|
47
|
+
The address names an event the calendar doesn't have here.
|
|
48
|
+
</p>
|
|
49
|
+
<button
|
|
50
|
+
type="button"
|
|
51
|
+
onClick={onClose}
|
|
52
|
+
className="rounded-md border border-line px-2.5 py-1 text-sm font-medium text-fg outline-none hover:bg-surface-sunken focus-visible:ring-2 focus-visible:ring-ring"
|
|
53
|
+
>
|
|
54
|
+
Back to the calendar
|
|
55
|
+
</button>
|
|
56
|
+
</div>
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return (
|
|
61
|
+
<div className="flex h-full min-h-0 flex-col bg-surface">
|
|
62
|
+
{isOccurrence && (
|
|
63
|
+
<p className="shrink-0 border-b border-line bg-surface-sunken px-row-inset py-1.5 text-2xs uppercase tracking-wider text-fg-subtle">
|
|
64
|
+
One occurrence of a repeating event
|
|
65
|
+
</p>
|
|
66
|
+
)}
|
|
67
|
+
{problem !== "" && (
|
|
68
|
+
<div
|
|
69
|
+
role="alert"
|
|
70
|
+
className="flex shrink-0 items-start gap-2 border-b border-danger/40 bg-danger-soft px-row-inset py-2 text-sm"
|
|
71
|
+
>
|
|
72
|
+
<AlertCircle
|
|
73
|
+
className="mt-0.5 size-4 shrink-0 text-danger"
|
|
74
|
+
aria-hidden="true"
|
|
75
|
+
/>
|
|
76
|
+
<p className="min-w-0 flex-1 break-words text-fg">{problem}</p>
|
|
77
|
+
</div>
|
|
78
|
+
)}
|
|
79
|
+
<EventDetail
|
|
80
|
+
event={event}
|
|
81
|
+
calendar={calendar}
|
|
82
|
+
whenText={formatEventWhen(event)}
|
|
83
|
+
onEdit={onEdit}
|
|
84
|
+
onDelete={onDelete}
|
|
85
|
+
onClose={onClose}
|
|
86
|
+
className="min-h-0 flex-1"
|
|
87
|
+
/>
|
|
88
|
+
</div>
|
|
89
|
+
);
|
|
90
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { AppShellSlotted } from "@remit/ui";
|
|
2
|
+
import { createContext, type ReactNode, useContext } from "react";
|
|
3
|
+
import { AppShellSkeleton } from "@/components/layout/AppShellSkeleton";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The chrome the calendar shares with mail, published by the `/calendar` layout
|
|
7
|
+
* and consumed by the view route that mounts the shell.
|
|
8
|
+
*
|
|
9
|
+
* It lives in a module reached only through the `@/` alias for the same reason
|
|
10
|
+
* `lib/mail-context.ts` does: the generated route tree imports route files
|
|
11
|
+
* relatively, so a context declared inside one would resolve to a second module
|
|
12
|
+
* instance and hand the consumer an empty default.
|
|
13
|
+
*/
|
|
14
|
+
export interface CalendarShellChrome {
|
|
15
|
+
/** Below the reading boundary the shell shows one pane, as mail's does. */
|
|
16
|
+
isSinglePane: boolean;
|
|
17
|
+
isLoading: boolean;
|
|
18
|
+
nav: ReactNode;
|
|
19
|
+
navOpen: boolean;
|
|
20
|
+
onOpenNav: () => void;
|
|
21
|
+
onCloseNav: () => void;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const CalendarShellCtx = createContext<CalendarShellChrome | null>(null);
|
|
25
|
+
|
|
26
|
+
export const CalendarShellProvider = ({
|
|
27
|
+
chrome,
|
|
28
|
+
children,
|
|
29
|
+
}: {
|
|
30
|
+
chrome: CalendarShellChrome;
|
|
31
|
+
children: ReactNode;
|
|
32
|
+
}) => (
|
|
33
|
+
<CalendarShellCtx.Provider value={chrome}>
|
|
34
|
+
{children}
|
|
35
|
+
</CalendarShellCtx.Provider>
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
export interface CalendarShellProps {
|
|
39
|
+
/** The grid, or whatever the current zoom draws. */
|
|
40
|
+
workspace: ReactNode;
|
|
41
|
+
/** The open event, from the route below this one. */
|
|
42
|
+
reading: ReactNode;
|
|
43
|
+
/** Whether the address has an event or the composer open. */
|
|
44
|
+
hasOpenEvent: boolean;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The shell the calendar mounts around its own panes.
|
|
49
|
+
*
|
|
50
|
+
* `listBias="list"` because the grid is the work rather than an index into it:
|
|
51
|
+
* a week of columns costs width it cannot give back when a detail opens, which
|
|
52
|
+
* is the opposite of the split a message list wants.
|
|
53
|
+
*
|
|
54
|
+
* Below the reading boundary there is one pane, so the open event replaces the
|
|
55
|
+
* grid in it — the same swap mail makes, read off the address rather than
|
|
56
|
+
* decided here.
|
|
57
|
+
*/
|
|
58
|
+
export function CalendarShell({
|
|
59
|
+
workspace,
|
|
60
|
+
reading,
|
|
61
|
+
hasOpenEvent,
|
|
62
|
+
}: CalendarShellProps) {
|
|
63
|
+
const chrome = useContext(CalendarShellCtx);
|
|
64
|
+
if (!chrome) return <AppShellSkeleton />;
|
|
65
|
+
|
|
66
|
+
const shared = {
|
|
67
|
+
nav: chrome.nav,
|
|
68
|
+
skeleton: <AppShellSkeleton />,
|
|
69
|
+
isLoading: chrome.isLoading,
|
|
70
|
+
navOpen: chrome.navOpen,
|
|
71
|
+
onOpenNav: chrome.onOpenNav,
|
|
72
|
+
onCloseNav: chrome.onCloseNav,
|
|
73
|
+
listBias: "list" as const,
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
if (chrome.isSinglePane) {
|
|
77
|
+
return (
|
|
78
|
+
<AppShellSlotted {...shared} list={hasOpenEvent ? reading : workspace} />
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return <AppShellSlotted {...shared} list={workspace} reading={reading} />;
|
|
83
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { CalendarViewId } from "@remit/ui";
|
|
2
|
+
import { CalendarRange } from "lucide-react";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A zoom level that is named but not drawn yet.
|
|
6
|
+
*
|
|
7
|
+
* The ladder is five steps and three of them arrive later, so the route stays
|
|
8
|
+
* addressable and says what it is waiting on. A view that silently rendered
|
|
9
|
+
* nothing would be indistinguishable from a week with nothing in it.
|
|
10
|
+
*/
|
|
11
|
+
const WAITING_ON: Record<string, string> = {
|
|
12
|
+
year: "The year grid arrives with the rest of the zoom ladder.",
|
|
13
|
+
month: "The month grid arrives with the rest of the zoom ladder.",
|
|
14
|
+
agenda: "The agenda arrives with the scrolling day strip.",
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export interface CalendarViewPlaceholderProps {
|
|
18
|
+
view: CalendarViewId;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function CalendarViewPlaceholder({
|
|
22
|
+
view,
|
|
23
|
+
}: CalendarViewPlaceholderProps) {
|
|
24
|
+
return (
|
|
25
|
+
<div
|
|
26
|
+
className="flex h-full flex-col items-center justify-center gap-2 bg-surface p-8 text-center"
|
|
27
|
+
data-testid={`calendar-placeholder-${view}`}
|
|
28
|
+
>
|
|
29
|
+
<CalendarRange className="size-8 text-fg-subtle" aria-hidden="true" />
|
|
30
|
+
<p className="text-sm font-medium text-fg">Not built yet</p>
|
|
31
|
+
<p className="max-w-xs text-sm text-fg-muted">
|
|
32
|
+
{WAITING_ON[view] ?? "This view arrives in a later stage."} Week and Day
|
|
33
|
+
work now.
|
|
34
|
+
</p>
|
|
35
|
+
</div>
|
|
36
|
+
);
|
|
37
|
+
}
|