@remit/web-client 0.0.287 → 0.0.289

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.
Files changed (25) hide show
  1. package/package.json +1 -1
  2. package/src/auth/SessionRateLimited.stories.tsx +1 -1
  3. package/src/components/calendar/AgendaStrip.stories.tsx +1 -1
  4. package/src/components/calendar/AgendaView.stories.tsx +1 -1
  5. package/src/components/calendar/CalendarComposePane.stories.tsx +1 -1
  6. package/src/components/calendar/CalendarEventPane.stories.tsx +1 -1
  7. package/src/components/calendar/CalendarShell.stories.tsx +1 -1
  8. package/src/components/calendar/CalendarViewPlaceholder.stories.tsx +1 -1
  9. package/src/components/calendar/CalendarWorkspace.stories.tsx +10 -1
  10. package/src/components/calendar/CalendarWorkspace.tsx +76 -43
  11. package/src/components/calendar/OpenCalendarEvent.stories.tsx +1 -1
  12. package/src/components/calendar/WriteCalendarEvent.stories.tsx +1 -1
  13. package/src/components/calendar/calendar-workspace.render.test.ts +24 -0
  14. package/src/components/compose/FromSelector.stories.tsx +1 -1
  15. package/src/components/config-import/config-import.stories.tsx +1 -1
  16. package/src/components/mail/authenticity-warning.stories.tsx +1 -1
  17. package/src/components/mail/blocked-images-notice.stories.tsx +1 -1
  18. package/src/components/mail/empty-trash-bar.stories.tsx +1 -1
  19. package/src/components/mail/intelligence-drawer.stories.tsx +1 -1
  20. package/src/components/settings/CalendarFeedCard.stories.tsx +1 -1
  21. package/src/components/settings/LabelsList.stories.tsx +1 -1
  22. package/src/components/settings/label-delete-confirm.stories.tsx +1 -1
  23. package/src/components/settings/settings-filter.stories.tsx +1 -1
  24. package/src/components/ui/ErrorBanner.stories.tsx +1 -1
  25. 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.287",
3
+ "version": "0.0.289",
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": {
@@ -7,7 +7,7 @@ import { SessionRateLimited } from "./SessionRateLimited";
7
7
  * told everyone behind the address that they had been logged out.
8
8
  */
9
9
  const meta: Meta<typeof SessionRateLimited> = {
10
- title: "Screens/SessionRateLimited",
10
+ title: "Playground/Shipped/Sign in/Session rate limited",
11
11
  component: SessionRateLimited,
12
12
  parameters: { layout: "fullscreen" },
13
13
  args: {
@@ -199,7 +199,7 @@ function Strip({
199
199
  }
200
200
 
201
201
  const meta: Meta<typeof Strip> = {
202
- title: "App/Calendar/Agenda",
202
+ title: "Playground/Shipped/Calendar/Agenda",
203
203
  component: Strip,
204
204
  parameters: { layout: "fullscreen" },
205
205
  args: {
@@ -56,7 +56,7 @@ function Agenda({
56
56
  }
57
57
 
58
58
  const meta: Meta<typeof Agenda> = {
59
- title: "App/Calendar/Agenda view",
59
+ title: "Playground/Shipped/Calendar/Agenda view",
60
60
  component: Agenda,
61
61
  parameters: { layout: "fullscreen" },
62
62
  args: {
@@ -45,7 +45,7 @@ const draft: EventDraft = {
45
45
  };
46
46
 
47
47
  const meta: Meta<typeof CalendarComposePane> = {
48
- title: "App/Calendar/Compose pane",
48
+ title: "Playground/Shipped/Calendar/Compose pane",
49
49
  component: CalendarComposePane,
50
50
  parameters: { layout: "fullscreen" },
51
51
  args: {
@@ -44,7 +44,7 @@ const event: CalendarEventData = {
44
44
  };
45
45
 
46
46
  const meta: Meta<typeof CalendarEventPane> = {
47
- title: "App/Calendar/Event pane",
47
+ title: "Playground/Shipped/Calendar/Event pane",
48
48
  component: CalendarEventPane,
49
49
  parameters: { layout: "fullscreen" },
50
50
  args: {
@@ -127,7 +127,7 @@ function Shell({
127
127
  }
128
128
 
129
129
  const meta: Meta<typeof Shell> = {
130
- title: "App/Calendar/Shell",
130
+ title: "Playground/Shipped/Calendar/Shell",
131
131
  component: Shell,
132
132
  parameters: { layout: "fullscreen" },
133
133
  };
@@ -11,7 +11,7 @@ import { CalendarViewPlaceholder } from "./CalendarViewPlaceholder";
11
11
  * and the reader would plan around an empty screen.
12
12
  */
13
13
  const meta: Meta<typeof CalendarViewPlaceholder> = {
14
- title: "App/Calendar/Not built yet",
14
+ title: "Playground/Shipped/Calendar/Not built yet",
15
15
  component: CalendarViewPlaceholder,
16
16
  parameters: { layout: "fullscreen" },
17
17
  render: (args) => (
@@ -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,12 +148,13 @@ function Workspace({
146
148
  onChangeDensity={setDensity}
147
149
  onSelectEvent={setSelected}
148
150
  onPickSlot={() => undefined}
151
+ touch={touch}
149
152
  />
150
153
  );
151
154
  }
152
155
 
153
156
  const meta: Meta<typeof Workspace> = {
154
- title: "App/Calendar/Workspace",
157
+ title: "Playground/Shipped/Calendar/Workspace",
155
158
  component: Workspace,
156
159
  parameters: { layout: "fullscreen" },
157
160
  args: { initialView: "week", events: week },
@@ -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
@@ -74,7 +74,7 @@ function Opened({
74
74
  }
75
75
 
76
76
  const meta: Meta<typeof Opened> = {
77
- title: "App/Calendar/Open event",
77
+ title: "Playground/Shipped/Calendar/Open event",
78
78
  component: Opened,
79
79
  parameters: { layout: "fullscreen" },
80
80
  args: { calendarObjectId: ROADMAP_OBJECT, server: answering() },
@@ -48,7 +48,7 @@ function Composer({ pick }: { pick?: CalendarSlotPick }) {
48
48
  }
49
49
 
50
50
  const meta: Meta<typeof Composer> = {
51
- title: "App/Calendar/Write event",
51
+ title: "Playground/Shipped/Calendar/Write event",
52
52
  component: Composer,
53
53
  parameters: { layout: "fullscreen" },
54
54
  };
@@ -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"));
@@ -28,7 +28,7 @@ function seededClient(): QueryClient {
28
28
  }
29
29
 
30
30
  const meta: Meta<typeof FromSelector> = {
31
- title: "Components/FromSelector",
31
+ title: "Playground/Shipped/Mail/Compose/FromSelector",
32
32
  component: FromSelector,
33
33
  parameters: { layout: "padded" },
34
34
  decorators: [
@@ -37,7 +37,7 @@ import {
37
37
  } from "./steps";
38
38
 
39
39
  const meta: Meta = {
40
- title: "Flows/Config import",
40
+ title: "Playground/Shipped/Import/Config import",
41
41
  parameters: { layout: "fullscreen" },
42
42
  };
43
43
  export default meta;
@@ -15,7 +15,7 @@ import { AuthenticityBanner } from "@/components/mail/AuthenticityBanner";
15
15
  * body's own size rather than floating off to the right as chrome.
16
16
  */
17
17
  const meta: Meta<typeof AuthenticityBanner> = {
18
- title: "Flows/Reading/Authenticity Warning",
18
+ title: "Playground/Shipped/Mail/Reading/Authenticity warning",
19
19
  component: AuthenticityBanner,
20
20
  parameters: { layout: "centered" },
21
21
  };
@@ -12,7 +12,7 @@ import { BlockedImagesNotice } from "@/components/mail/BlockedImagesNotice";
12
12
  * message that explains nothing is indistinguishable from a broken one.
13
13
  */
14
14
  const meta: Meta<typeof BlockedImagesNotice> = {
15
- title: "Flows/Mail/Blocked Images Notice",
15
+ title: "Playground/Shipped/Mail/Reading/Blocked images notice",
16
16
  component: BlockedImagesNotice,
17
17
  parameters: { layout: "padded" },
18
18
  };
@@ -13,7 +13,7 @@ import { EmptyTrashBar } from "@/components/mail/EmptyTrashBar";
13
13
  * lands under the button and stays there until the user repairs it.
14
14
  */
15
15
  const meta: Meta<typeof EmptyTrashBar> = {
16
- title: "Flows/Mail/Empty Trash",
16
+ title: "Playground/Shipped/Mail/Empty trash",
17
17
  component: EmptyTrashBar,
18
18
  parameters: { layout: "fullscreen" },
19
19
  };
@@ -27,7 +27,7 @@ import { useIntelligenceDrawer } from "@/hooks/useIntelligenceDrawer";
27
27
  * mid-width panes both run on.
28
28
  */
29
29
  const meta: Meta = {
30
- title: "Flows/Reading/Intelligence Drawer",
30
+ title: "Playground/Shipped/Mail/Reading/Intelligence drawer",
31
31
  parameters: { layout: "centered" },
32
32
  };
33
33
  export default meta;
@@ -7,7 +7,7 @@ import { CalendarFeedCard } from "./CalendarFeedCard";
7
7
  * is legible and the two confirmations that take it away.
8
8
  */
9
9
  const meta: Meta<typeof CalendarFeedCard> = {
10
- title: "Flows/Settings Calendars/Subscription address",
10
+ title: "Playground/Shipped/Settings/Calendars/Subscription address",
11
11
  component: CalendarFeedCard,
12
12
  parameters: { layout: "padded" },
13
13
  args: {
@@ -3,7 +3,7 @@ import type { Meta, StoryObj } from "@storybook/react-vite";
3
3
  import { LabelsList } from "./LabelsList";
4
4
 
5
5
  const meta: Meta<typeof LabelsList> = {
6
- title: "Flows/Settings Labels/LabelsList",
6
+ title: "Playground/Shipped/Settings/Labels/LabelsList",
7
7
  component: LabelsList,
8
8
  parameters: { layout: "padded" },
9
9
  decorators: [
@@ -10,7 +10,7 @@ import { deleteLabelConfirmCopy } from "@/lib/organize/label-delete-copy";
10
10
  * approved surface, not a placeholder for one.
11
11
  */
12
12
  const meta: Meta<typeof ConfirmDialog> = {
13
- title: "Flows/Settings Labels/Delete Confirmation",
13
+ title: "Playground/Shipped/Settings/Labels/Delete confirmation",
14
14
  component: ConfirmDialog,
15
15
  parameters: { layout: "centered" },
16
16
  };
@@ -103,7 +103,7 @@ function SheetStage({ children }: { children: ReactNode }) {
103
103
  }
104
104
 
105
105
  const meta: Meta = {
106
- title: "Flows/Settings Filters",
106
+ title: "Playground/Shipped/Settings/Filters",
107
107
  parameters: { layout: "fullscreen" },
108
108
  };
109
109
  export default meta;
@@ -12,7 +12,7 @@ import { ErrorBanner } from "./ErrorBanner";
12
12
  * builds from build-time constants Storybook has no `define` for.
13
13
  */
14
14
  const meta: Meta<typeof ErrorBanner> = {
15
- title: "Components/ErrorBanner",
15
+ title: "Playground/Shipped/Mail/ErrorBanner",
16
16
  component: ErrorBanner,
17
17
  parameters: { layout: "padded" },
18
18
  args: {
@@ -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