@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/web-client",
3
- "version": "0.0.288",
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": {
@@ -1,47 +1,6 @@
1
- import { Avatar } from "@remit/ui";
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
- segmentClassName,
10
+ Segmented,
11
+ type SegmentOption,
11
12
  } from "@remit/ui";
12
13
  import { Loader2 } from "lucide-react";
13
- import { type ReactNode, useId, useState } from "react";
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 DENSITY_OPTIONS: { value: Density; label: string }[] = [
34
- { value: "comfortable", label: "Detail" },
35
- { value: "compact", label: "Glance" },
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
- const densityGroup = useId();
117
- // The grid computes the range it drew, which is the only honest title for a
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
- <div className="flex h-pane-header shrink-0 items-center gap-2 border-b border-line px-2">
130
- {/* Below the nav pane the sidebar is a slide-over with nothing to open
131
- it: the calendar is a whole surface of its own, so without this
132
- the reader has no way back to their mail. */}
133
- <NavMenuButton />
134
- <CalendarDateNav
135
- title={title}
136
- onPrev={() => onStep(-1)}
137
- onNext={() => onStep(1)}
138
- onToday={onToday}
139
- >
140
- <fieldset className="inline-flex items-center gap-0.5">
141
- <legend className="sr-only">Calendar density</legend>
142
- {DENSITY_OPTIONS.map((option) => (
143
- <label
144
- key={option.value}
145
- className={`${segmentClassName(density === option.value)} h-7 text-xs`}
146
- >
147
- <input
148
- type="radio"
149
- name={densityGroup}
150
- value={option.value}
151
- checked={density === option.value}
152
- onChange={() => onChangeDensity(option.value)}
153
- className="sr-only"
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
- </CalendarDateNav>
161
- </div>
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 { createFileRoute, Outlet, redirect } from "@tanstack/react-router";
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