@remit/web-client 0.0.288 → 0.0.290
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/auth/AccountMenu.tsx +1 -42
- package/src/components/calendar/CalendarWorkspace.stories.tsx +9 -0
- package/src/components/calendar/CalendarWorkspace.tsx +76 -43
- package/src/components/calendar/calendar-workspace.render.test.ts +24 -0
- package/src/components/ui/AppVersion.stories.tsx +31 -0
- package/src/components/ui/BugReportButton.stories.tsx +19 -0
- package/src/components/ui/FatalErrorOverlay.stories.tsx +55 -0
- package/src/routes/calendar/$view.$date.tsx +23 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@remit/web-client",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.290",
|
|
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": {
|
package/src/auth/AccountMenu.tsx
CHANGED
|
@@ -1,47 +1,6 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { LogOut } from "lucide-react";
|
|
3
|
-
import {
|
|
4
|
-
DropdownMenu,
|
|
5
|
-
DropdownMenuItem,
|
|
6
|
-
DropdownMenuSeparator,
|
|
7
|
-
} from "@/components/ui/DropdownMenu";
|
|
1
|
+
import { AccountMenu as AccountMenuView } from "@remit/ui";
|
|
8
2
|
import { AccountSession } from "./AccountSession";
|
|
9
3
|
|
|
10
|
-
interface AccountMenuViewProps {
|
|
11
|
-
email: string | null;
|
|
12
|
-
onSignOut: () => void;
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
const AccountMenuView = ({ email, onSignOut }: AccountMenuViewProps) => {
|
|
16
|
-
const displayName = email ?? "Account";
|
|
17
|
-
|
|
18
|
-
return (
|
|
19
|
-
<DropdownMenu
|
|
20
|
-
trigger={
|
|
21
|
-
<Avatar name={displayName} email={email ?? undefined} size="sm" />
|
|
22
|
-
}
|
|
23
|
-
align="right"
|
|
24
|
-
>
|
|
25
|
-
{email && (
|
|
26
|
-
<>
|
|
27
|
-
<div
|
|
28
|
-
className="px-3 py-2 text-xs text-fg-muted truncate"
|
|
29
|
-
title={email}
|
|
30
|
-
data-testid="account-menu-email"
|
|
31
|
-
>
|
|
32
|
-
{email}
|
|
33
|
-
</div>
|
|
34
|
-
<DropdownMenuSeparator />
|
|
35
|
-
</>
|
|
36
|
-
)}
|
|
37
|
-
<DropdownMenuItem onClick={onSignOut}>
|
|
38
|
-
<LogOut className="size-4" />
|
|
39
|
-
Sign out
|
|
40
|
-
</DropdownMenuItem>
|
|
41
|
-
</DropdownMenu>
|
|
42
|
-
);
|
|
43
|
-
};
|
|
44
|
-
|
|
45
4
|
export const AccountMenu = () => (
|
|
46
5
|
<AccountSession>
|
|
47
6
|
{({ email, signOut }) => (
|
|
@@ -111,11 +111,13 @@ function Workspace({
|
|
|
111
111
|
events,
|
|
112
112
|
isLoading,
|
|
113
113
|
error,
|
|
114
|
+
touch,
|
|
114
115
|
}: {
|
|
115
116
|
initialView: CalendarViewId;
|
|
116
117
|
events: CalendarEventData[];
|
|
117
118
|
isLoading?: boolean;
|
|
118
119
|
error?: unknown;
|
|
120
|
+
touch?: boolean;
|
|
119
121
|
}) {
|
|
120
122
|
const [view, setView] = useState<CalendarViewId>(initialView);
|
|
121
123
|
const [density, setDensity] = useState<Density>("comfortable");
|
|
@@ -146,6 +148,7 @@ function Workspace({
|
|
|
146
148
|
onChangeDensity={setDensity}
|
|
147
149
|
onSelectEvent={setSelected}
|
|
148
150
|
onPickSlot={() => undefined}
|
|
151
|
+
touch={touch}
|
|
149
152
|
/>
|
|
150
153
|
);
|
|
151
154
|
}
|
|
@@ -169,6 +172,12 @@ export const Week: Story = {};
|
|
|
169
172
|
|
|
170
173
|
export const Day: Story = { args: { initialView: "day" } };
|
|
171
174
|
|
|
175
|
+
/** A phone: two rows of thumb-sized controls, and no year on the ladder. */
|
|
176
|
+
export const Phone: Story = {
|
|
177
|
+
args: { touch: true },
|
|
178
|
+
globals: { viewport: { value: "mobile" } },
|
|
179
|
+
};
|
|
180
|
+
|
|
172
181
|
/** Nothing scheduled is still a grid, never a "nothing here" surface. */
|
|
173
182
|
export const EmptyWeek: Story = { args: { events: [] } };
|
|
174
183
|
|
|
@@ -7,10 +7,11 @@ import {
|
|
|
7
7
|
type CalendarViewId,
|
|
8
8
|
CalendarViewSwitch,
|
|
9
9
|
type Density,
|
|
10
|
-
|
|
10
|
+
Segmented,
|
|
11
|
+
type SegmentOption,
|
|
11
12
|
} from "@remit/ui";
|
|
12
13
|
import { Loader2 } from "lucide-react";
|
|
13
|
-
import { type ReactNode,
|
|
14
|
+
import { type ReactNode, useState } from "react";
|
|
14
15
|
import { CalendarViewPlaceholder } from "@/components/calendar/CalendarViewPlaceholder";
|
|
15
16
|
import { NavMenuButton } from "@/components/mail/NavMenuButton";
|
|
16
17
|
import { ErrorState } from "@/components/ui/ErrorState";
|
|
@@ -30,9 +31,11 @@ import {
|
|
|
30
31
|
* rather than of this component's state.
|
|
31
32
|
*/
|
|
32
33
|
|
|
33
|
-
const
|
|
34
|
-
|
|
35
|
-
|
|
34
|
+
const PHONE_VIEWS: CalendarViewId[] = ["month", "week", "day", "agenda"];
|
|
35
|
+
|
|
36
|
+
const DENSITY_OPTIONS: SegmentOption<Density>[] = [
|
|
37
|
+
{ value: "comfortable", label: "Detail", shortLabel: "Detail" },
|
|
38
|
+
{ value: "compact", label: "Glance", shortLabel: "Glance" },
|
|
36
39
|
];
|
|
37
40
|
|
|
38
41
|
export interface CalendarWorkspaceProps {
|
|
@@ -68,11 +71,16 @@ export interface CalendarWorkspaceProps {
|
|
|
68
71
|
onSelectEvent: (eventId: string) => void;
|
|
69
72
|
onPickSlot: (pick: CalendarSlotPick) => void;
|
|
70
73
|
onStep: (direction: -1 | 1) => void;
|
|
74
|
+
/**
|
|
75
|
+
* A phone: the toolbar splits into two rows of thumb-sized controls, because
|
|
76
|
+
* one row of them is wider than the screen and pushes the grid off it.
|
|
77
|
+
*/
|
|
78
|
+
touch?: boolean;
|
|
71
79
|
}
|
|
72
80
|
|
|
73
81
|
/**
|
|
74
82
|
* The range the toolbar names before the grid has measured one, and the whole
|
|
75
|
-
* answer at a zoom that draws no grid.
|
|
83
|
+
* answer for a week and at a zoom that draws no grid.
|
|
76
84
|
*/
|
|
77
85
|
function fallbackRangeTitle(view: CalendarViewId, date: string): string {
|
|
78
86
|
const instant = new Date(`${date}T00:00:00`);
|
|
@@ -80,6 +88,17 @@ function fallbackRangeTitle(view: CalendarViewId, date: string): string {
|
|
|
80
88
|
return new Intl.DateTimeFormat(undefined, { year: "numeric" }).format(
|
|
81
89
|
instant,
|
|
82
90
|
);
|
|
91
|
+
if (view === "week") {
|
|
92
|
+
const monday = new Date(instant);
|
|
93
|
+
monday.setDate(instant.getDate() - ((instant.getDay() + 6) % 7));
|
|
94
|
+
const sunday = new Date(monday);
|
|
95
|
+
sunday.setDate(monday.getDate() + 6);
|
|
96
|
+
return new Intl.DateTimeFormat(undefined, {
|
|
97
|
+
day: "numeric",
|
|
98
|
+
month: "short",
|
|
99
|
+
year: "numeric",
|
|
100
|
+
}).formatRange(monday, sunday);
|
|
101
|
+
}
|
|
83
102
|
if (view === "day")
|
|
84
103
|
return new Intl.DateTimeFormat(undefined, {
|
|
85
104
|
weekday: "short",
|
|
@@ -112,53 +131,67 @@ export function CalendarWorkspace({
|
|
|
112
131
|
onSelectEvent,
|
|
113
132
|
onPickSlot,
|
|
114
133
|
onStep,
|
|
134
|
+
touch = false,
|
|
115
135
|
}: CalendarWorkspaceProps) {
|
|
116
|
-
|
|
117
|
-
//
|
|
118
|
-
// week — it knows where the week starts. Held against the address it was
|
|
119
|
-
// measured for, so leaving a view does not carry its title into the next one.
|
|
136
|
+
// Held against the address it was measured for, so leaving a view does not
|
|
137
|
+
// carry its title into the next one.
|
|
120
138
|
const [measured, setMeasured] = useState({ key: "", title: "" });
|
|
121
139
|
const addressKey = `${view}/${date}`;
|
|
122
140
|
const title =
|
|
123
|
-
measured.key === addressKey
|
|
141
|
+
view !== "week" && measured.key === addressKey
|
|
124
142
|
? measured.title
|
|
125
143
|
: fallbackRangeTitle(view, date);
|
|
126
144
|
|
|
145
|
+
const dateNav = (
|
|
146
|
+
<CalendarDateNav
|
|
147
|
+
title={title}
|
|
148
|
+
onPrev={() => onStep(-1)}
|
|
149
|
+
onNext={() => onStep(1)}
|
|
150
|
+
onToday={onToday}
|
|
151
|
+
touch={touch}
|
|
152
|
+
/>
|
|
153
|
+
);
|
|
154
|
+
const densitySwitch = (
|
|
155
|
+
<Segmented
|
|
156
|
+
ariaLabel="Calendar density"
|
|
157
|
+
options={DENSITY_OPTIONS}
|
|
158
|
+
value={density}
|
|
159
|
+
onChange={onChangeDensity}
|
|
160
|
+
touch={touch}
|
|
161
|
+
className="shrink-0"
|
|
162
|
+
/>
|
|
163
|
+
);
|
|
164
|
+
|
|
127
165
|
return (
|
|
128
166
|
<div className="flex h-full min-h-0 flex-col bg-surface">
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
/>
|
|
155
|
-
{option.label}
|
|
156
|
-
</label>
|
|
157
|
-
))}
|
|
158
|
-
</fieldset>
|
|
167
|
+
{touch ? (
|
|
168
|
+
<div className="flex shrink-0 flex-col gap-1 border-b border-line px-2 pb-1">
|
|
169
|
+
<div className="flex min-w-0 items-center gap-1">
|
|
170
|
+
<NavMenuButton />
|
|
171
|
+
<div className="min-w-0 flex-1">{dateNav}</div>
|
|
172
|
+
</div>
|
|
173
|
+
<div className="flex items-center gap-2">
|
|
174
|
+
<CalendarViewSwitch
|
|
175
|
+
value={view}
|
|
176
|
+
onChange={onChangeView}
|
|
177
|
+
views={PHONE_VIEWS}
|
|
178
|
+
touch
|
|
179
|
+
className="flex-1"
|
|
180
|
+
/>
|
|
181
|
+
{densitySwitch}
|
|
182
|
+
</div>
|
|
183
|
+
</div>
|
|
184
|
+
) : (
|
|
185
|
+
<div className="flex h-pane-header shrink-0 items-center gap-2 border-b border-line px-2">
|
|
186
|
+
{/* Below the nav pane the sidebar is a slide-over with nothing to open
|
|
187
|
+
it: the calendar is a whole surface of its own, so without this
|
|
188
|
+
the reader has no way back to their mail. */}
|
|
189
|
+
<NavMenuButton />
|
|
190
|
+
<div className="min-w-0 flex-1">{dateNav}</div>
|
|
191
|
+
{densitySwitch}
|
|
159
192
|
<CalendarViewSwitch value={view} onChange={onChangeView} />
|
|
160
|
-
</
|
|
161
|
-
|
|
193
|
+
</div>
|
|
194
|
+
)}
|
|
162
195
|
|
|
163
196
|
{/* The strip rolls its own range, so it answers for its own days: the
|
|
164
197
|
week this pane read is not what it is drawing, and gating it on that
|
|
@@ -156,6 +156,30 @@ describe("the toolbar", () => {
|
|
|
156
156
|
assert.ok(render("year").includes("2026"));
|
|
157
157
|
});
|
|
158
158
|
|
|
159
|
+
it("names a week that runs over a year end by both of its years", () => {
|
|
160
|
+
const html = renderQuietly(
|
|
161
|
+
createElement(CalendarWorkspace, {
|
|
162
|
+
...workspaceProps("week", []),
|
|
163
|
+
date: "2025-12-31",
|
|
164
|
+
}),
|
|
165
|
+
);
|
|
166
|
+
const title = /<h2[^>]*>([^<]*)<\/h2>/.exec(html)?.[1] ?? "";
|
|
167
|
+
assert.match(title, /2025/, `"${title}" lost the year the week starts in`);
|
|
168
|
+
assert.match(title, /2026/, `"${title}" lost the year the week ends in`);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it("leaves the year off the ladder on a phone", () => {
|
|
172
|
+
const html = renderQuietly(
|
|
173
|
+
createElement(CalendarWorkspace, {
|
|
174
|
+
...workspaceProps("week"),
|
|
175
|
+
touch: true,
|
|
176
|
+
}),
|
|
177
|
+
);
|
|
178
|
+
assert.equal(html.includes(">Year<"), false);
|
|
179
|
+
assert.equal(html.includes(">Y<"), false);
|
|
180
|
+
assert.ok(html.includes(">W<"), "the phone ladder did not render");
|
|
181
|
+
});
|
|
182
|
+
|
|
159
183
|
it("carries the density control, which the address never names", () => {
|
|
160
184
|
const html = render("week");
|
|
161
185
|
assert.ok(html.includes("Calendar density"));
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
2
|
+
import { AppVersion } from "./AppVersion";
|
|
3
|
+
|
|
4
|
+
const meta: Meta<typeof AppVersion> = {
|
|
5
|
+
title: "Playground/Shipped/Settings/Advanced/AppVersion",
|
|
6
|
+
component: AppVersion,
|
|
7
|
+
parameters: { layout: "padded" },
|
|
8
|
+
args: {
|
|
9
|
+
sha: "a1b2c3d",
|
|
10
|
+
commitUrl: "https://github.com/remit-mail/reader/commit/a1b2c3d4e5f6",
|
|
11
|
+
buildTime: "2024-06-12T10:30:00.000Z",
|
|
12
|
+
},
|
|
13
|
+
};
|
|
14
|
+
export default meta;
|
|
15
|
+
|
|
16
|
+
type Story = StoryObj<typeof AppVersion>;
|
|
17
|
+
|
|
18
|
+
export const Default: Story = {};
|
|
19
|
+
|
|
20
|
+
export const DevBuild: Story = {
|
|
21
|
+
args: { sha: "dev", commitUrl: undefined },
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export const InAboutSection: Story = {
|
|
25
|
+
render: (args) => (
|
|
26
|
+
<div className="mt-4 max-w-sm border-t border-line pt-4">
|
|
27
|
+
<p className="mb-1 text-sm font-medium text-fg">About</p>
|
|
28
|
+
<AppVersion {...args} />
|
|
29
|
+
</div>
|
|
30
|
+
),
|
|
31
|
+
};
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
2
|
+
import { BugReportButton } from "./BugReportButton";
|
|
3
|
+
|
|
4
|
+
const meta: Meta<typeof BugReportButton> = {
|
|
5
|
+
title: "Playground/Shipped/Mail/Nav/BugReportButton",
|
|
6
|
+
component: BugReportButton,
|
|
7
|
+
parameters: { layout: "padded" },
|
|
8
|
+
};
|
|
9
|
+
export default meta;
|
|
10
|
+
|
|
11
|
+
type Story = StoryObj<typeof BugReportButton>;
|
|
12
|
+
|
|
13
|
+
export const Default: Story = {
|
|
14
|
+
render: () => (
|
|
15
|
+
<div className="w-64">
|
|
16
|
+
<BugReportButton />
|
|
17
|
+
</div>
|
|
18
|
+
),
|
|
19
|
+
};
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
2
|
+
import { expect, within } from "storybook/test";
|
|
3
|
+
import type { FatalError } from "@/lib/fatal-error";
|
|
4
|
+
import { FatalErrorScreen } from "./FatalErrorOverlay";
|
|
5
|
+
|
|
6
|
+
const meta: Meta<typeof FatalErrorScreen> = {
|
|
7
|
+
title: "Playground/Shipped/App/FatalErrorOverlay",
|
|
8
|
+
component: FatalErrorScreen,
|
|
9
|
+
parameters: { layout: "fullscreen" },
|
|
10
|
+
};
|
|
11
|
+
export default meta;
|
|
12
|
+
|
|
13
|
+
type Story = StoryObj<typeof FatalErrorScreen>;
|
|
14
|
+
|
|
15
|
+
const fatal = (overrides: Partial<FatalError>): FatalError => ({
|
|
16
|
+
error: new Error(overrides.message),
|
|
17
|
+
message: "",
|
|
18
|
+
correlationId: "a1b2c3d4-5678-90ab-cdef-1234567890ab",
|
|
19
|
+
at: Date.UTC(2024, 5, 12, 10, 30),
|
|
20
|
+
recoverable: true,
|
|
21
|
+
...overrides,
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
export const RecoverableTransient: Story = {
|
|
25
|
+
args: {
|
|
26
|
+
fatal: fatal({
|
|
27
|
+
message: "Request failed with status 500",
|
|
28
|
+
recoverable: true,
|
|
29
|
+
}),
|
|
30
|
+
},
|
|
31
|
+
play: async ({ canvasElement }) => {
|
|
32
|
+
const canvas = within(canvasElement);
|
|
33
|
+
await expect(canvas.getByRole("button", { name: "Retry" })).toBeVisible();
|
|
34
|
+
await expect(
|
|
35
|
+
canvas.queryByRole("link", { name: "Go to inbox" }),
|
|
36
|
+
).toBeNull();
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export const DeterministicFatal: Story = {
|
|
41
|
+
args: {
|
|
42
|
+
fatal: fatal({
|
|
43
|
+
message: "date value is not finite in DateTimeFormat format()",
|
|
44
|
+
correlationId: "def45678-90ab-cdef-1234-567890abcdef",
|
|
45
|
+
recoverable: false,
|
|
46
|
+
}),
|
|
47
|
+
},
|
|
48
|
+
play: async ({ canvasElement }) => {
|
|
49
|
+
const canvas = within(canvasElement);
|
|
50
|
+
await expect(
|
|
51
|
+
canvas.getByRole("link", { name: "Go to inbox" }),
|
|
52
|
+
).toBeVisible();
|
|
53
|
+
await expect(canvas.queryByRole("button", { name: "Retry" })).toBeNull();
|
|
54
|
+
},
|
|
55
|
+
};
|
|
@@ -14,7 +14,12 @@
|
|
|
14
14
|
* week instead of on an error.
|
|
15
15
|
*/
|
|
16
16
|
import type { CalendarSlotPick } from "@remit/ui";
|
|
17
|
-
import {
|
|
17
|
+
import {
|
|
18
|
+
createFileRoute,
|
|
19
|
+
Navigate,
|
|
20
|
+
Outlet,
|
|
21
|
+
redirect,
|
|
22
|
+
} from "@tanstack/react-router";
|
|
18
23
|
import { useCallback, useState } from "react";
|
|
19
24
|
import { AgendaView } from "@/components/calendar/AgendaView";
|
|
20
25
|
import { CalendarComposeSeedProvider } from "@/components/calendar/CalendarComposeSeed";
|
|
@@ -22,6 +27,7 @@ import { CalendarShell } from "@/components/calendar/CalendarShell";
|
|
|
22
27
|
import { CalendarWorkspace } from "@/components/calendar/CalendarWorkspace";
|
|
23
28
|
import { calendarInstanceId, deviceTimeZone } from "@/hooks/calendar";
|
|
24
29
|
import { useCalendarData } from "@/hooks/useCalendarData";
|
|
30
|
+
import { useLayoutTier } from "@/hooks/useLayoutTier";
|
|
25
31
|
import {
|
|
26
32
|
readCalendarDensity,
|
|
27
33
|
writeCalendarDensity,
|
|
@@ -56,6 +62,21 @@ export const Route = createFileRoute("/calendar/$view/$date")({
|
|
|
56
62
|
});
|
|
57
63
|
|
|
58
64
|
function CalendarViewLayout() {
|
|
65
|
+
const { view, date } = useCalendarAddress();
|
|
66
|
+
const isPhone = useLayoutTier() === "phone";
|
|
67
|
+
if (isPhone && view === "year")
|
|
68
|
+
return (
|
|
69
|
+
<Navigate
|
|
70
|
+
to="/calendar/$view/$date"
|
|
71
|
+
params={{ view: "month", date }}
|
|
72
|
+
search={true}
|
|
73
|
+
replace
|
|
74
|
+
/>
|
|
75
|
+
);
|
|
76
|
+
return <CalendarView isPhone={isPhone} />;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function CalendarView({ isPhone }: { isPhone: boolean }) {
|
|
59
80
|
const { view, date, calendarIds } = useCalendarAddress();
|
|
60
81
|
// The strip fetches the days it holds a week at a time and draws none of
|
|
61
82
|
// this, so at that zoom the layout asks for nothing: the address rewrites on
|
|
@@ -121,6 +142,7 @@ function CalendarViewLayout() {
|
|
|
121
142
|
onChangeDensity={changeDensity}
|
|
122
143
|
onSelectEvent={selectEvent}
|
|
123
144
|
onPickSlot={pickSlot}
|
|
145
|
+
touch={isPhone}
|
|
124
146
|
/>
|
|
125
147
|
);
|
|
126
148
|
|