@remit/ui 0.0.113 → 0.0.115

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 (63) hide show
  1. package/package.json +3 -3
  2. package/src/components/app-shell-slotted.render.test.ts +83 -0
  3. package/src/components/app-shell-slotted.tsx +31 -3
  4. package/src/components/attendee-row.render.test.ts +73 -0
  5. package/src/components/attendee-row.stories.tsx +68 -0
  6. package/src/components/attendee-row.tsx +96 -0
  7. package/src/components/calendar-event-chip.render.test.ts +67 -0
  8. package/src/components/calendar-event-chip.stories.tsx +128 -0
  9. package/src/components/calendar-event-chip.tsx +116 -0
  10. package/src/components/calendar-list.render.test.ts +77 -0
  11. package/src/components/calendar-list.stories.tsx +130 -0
  12. package/src/components/calendar-list.tsx +225 -0
  13. package/src/components/calendar-toolbar.render.test.ts +69 -0
  14. package/src/components/calendar-toolbar.stories.tsx +55 -0
  15. package/src/components/calendar-toolbar.tsx +178 -0
  16. package/src/components/calendar-types.ts +135 -0
  17. package/src/components/custom-recurrence.stories.tsx +106 -0
  18. package/src/components/custom-recurrence.tsx +411 -0
  19. package/src/components/event-detail.render.test.ts +104 -0
  20. package/src/components/event-detail.stories.tsx +178 -0
  21. package/src/components/event-detail.tsx +188 -0
  22. package/src/components/event-editor-pane.tsx +54 -0
  23. package/src/components/event-editor.render.test.ts +83 -0
  24. package/src/components/event-editor.stories.tsx +99 -0
  25. package/src/components/event-editor.tsx +480 -0
  26. package/src/components/event-quick-entry.render.test.ts +40 -0
  27. package/src/components/event-quick-entry.stories.tsx +56 -0
  28. package/src/components/event-quick-entry.tsx +159 -0
  29. package/src/components/event-suggestion-card.render.test.ts +65 -0
  30. package/src/components/event-suggestion-card.stories.tsx +93 -0
  31. package/src/components/event-suggestion-card.tsx +116 -0
  32. package/src/components/flow-screen.tsx +169 -0
  33. package/src/components/intelligence-panel.stories.tsx +5 -1
  34. package/src/components/intelligence-panel.tsx +4 -0
  35. package/src/components/isolated-email-frame.tsx +1 -18
  36. package/src/components/message-list-pane.render.test.ts +2 -2
  37. package/src/components/message-list-pane.stories.tsx +1 -1
  38. package/src/components/nav-sidebar.tsx +20 -0
  39. package/src/components/popover-menu.tsx +230 -27
  40. package/src/components/pull-to-refresh.tsx +1 -19
  41. package/src/components/recurrence-scope-prompt.render.test.ts +36 -0
  42. package/src/components/recurrence-scope-prompt.stories.tsx +46 -0
  43. package/src/components/recurrence-scope-prompt.tsx +84 -0
  44. package/src/components/rich-text-correction-menu.tsx +220 -0
  45. package/src/components/rich-text-editor.stories.tsx +507 -5
  46. package/src/components/rich-text-editor.tsx +482 -83
  47. package/src/components/rich-text-spellcheck-menu.test.ts +865 -0
  48. package/src/components/rich-text-spellcheck-provider.test.ts +114 -1
  49. package/src/components/rich-text-spellcheck-provider.ts +68 -3
  50. package/src/components/rich-text-spellcheck-words.ts +168 -4
  51. package/src/components/rich-text-spellcheck-worker.ts +11 -0
  52. package/src/components/rich-text-spellcheck.test.ts +7 -0
  53. package/src/components/rich-text-spellcheck.ts +28 -2
  54. package/src/components/selection-wizard.tsx +19 -69
  55. package/src/index.ts +116 -0
  56. package/src/lib/calendar-color.ts +71 -0
  57. package/src/lib/event-phrase.test.ts +89 -0
  58. package/src/lib/event-phrase.ts +228 -0
  59. package/src/lib/recurrence.test.ts +141 -0
  60. package/src/lib/recurrence.ts +266 -0
  61. package/src/lib/use-match-media.ts +25 -0
  62. package/src/rich-text.ts +12 -0
  63. package/src/tokens.css +63 -0
@@ -0,0 +1,116 @@
1
+ import { Globe, Mail, Repeat } from "lucide-react";
2
+ import { calendarColorClasses } from "../lib/calendar-color.js";
3
+ import { cn } from "../lib/cn.js";
4
+ import type { Density } from "./app-shell-types.js";
5
+ import type {
6
+ CalendarColorId,
7
+ RsvpState,
8
+ ZoneCertainty,
9
+ } from "./calendar-types.js";
10
+
11
+ export interface CalendarEventChipProps {
12
+ title: string;
13
+ /** Rendered ahead of the title; empty for an all-day entry. */
14
+ timeText: string;
15
+ color: CalendarColorId;
16
+ /**
17
+ * `row` is the horizontal pill of the all-day band and the month grid.
18
+ * `column` is the block that fills its slot in a time grid.
19
+ */
20
+ layout: "row" | "column";
21
+ density: Density;
22
+ rsvp: RsvpState;
23
+ status: "confirmed" | "tentative";
24
+ /** Carries the mail mark that says this event has a thread behind it. */
25
+ hasThread: boolean;
26
+ isRecurring: boolean;
27
+ zoneCertainty: ZoneCertainty;
28
+ selected: boolean;
29
+ onClick?: () => void;
30
+ }
31
+
32
+ /**
33
+ * One event, wherever an event is ours to render: the agenda, a day column of
34
+ * our own, a picker, the phone list. The calendar's hue is the only colour it
35
+ * carries; the RSVP and the zone ride on shape and mark instead, so a declined
36
+ * event in a green calendar still reads as green and as declined.
37
+ *
38
+ * A FullCalendar grid is the one surface that does not use it — the library
39
+ * renders the event's element itself and takes only a class string and the
40
+ * content inside, so `calendar-grid.tsx` restates this shell there. Every value
41
+ * that can be shared is the same on both sides; what cannot cross is the
42
+ * element itself, and with it `aria-pressed` and the focus ring.
43
+ */
44
+ export function CalendarEventChip({
45
+ title,
46
+ timeText,
47
+ color,
48
+ layout,
49
+ density,
50
+ rsvp,
51
+ status,
52
+ hasThread,
53
+ isRecurring,
54
+ zoneCertainty,
55
+ selected,
56
+ onClick,
57
+ }: CalendarEventChipProps) {
58
+ const hue = calendarColorClasses(color);
59
+ const isColumn = layout === "column";
60
+ const isCompact = density === "compact";
61
+ const declined = rsvp === "declined";
62
+ const provisional = rsvp === "tentative" || status === "tentative";
63
+
64
+ return (
65
+ <button
66
+ type="button"
67
+ onClick={onClick}
68
+ aria-pressed={selected}
69
+ className={cn(
70
+ "group flex w-full min-w-0 overflow-hidden text-left outline-none transition-colors",
71
+ "focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-surface",
72
+ hue.soft,
73
+ hue.text,
74
+ isColumn
75
+ ? "h-full flex-col rounded-sm border-l-2 px-1.5 py-0.5"
76
+ : "items-center gap-1.5 rounded-sm border-l-2 px-1.5 py-0.5",
77
+ hue.rail,
78
+ provisional && "border-y border-r border-dashed",
79
+ provisional && hue.border,
80
+ declined && "opacity-60",
81
+ selected && "ring-2 ring-ring",
82
+ isCompact ? "text-2xs" : "text-xs",
83
+ )}
84
+ >
85
+ <span
86
+ className={cn(
87
+ "flex min-w-0 items-center gap-1",
88
+ isColumn && "w-full",
89
+ declined && "line-through",
90
+ )}
91
+ >
92
+ {timeText !== "" && (
93
+ <span className="shrink-0 tabular-nums opacity-80">{timeText}</span>
94
+ )}
95
+ <span className="truncate font-medium">{title}</span>
96
+ </span>
97
+ {(hasThread || isRecurring || zoneCertainty === "ambiguous") && (
98
+ <span
99
+ className={cn(
100
+ "flex shrink-0 items-center gap-1",
101
+ isColumn ? "mt-0.5" : "ml-auto",
102
+ )}
103
+ >
104
+ {isRecurring && <Repeat className="size-2.5" aria-label="Repeats" />}
105
+ {hasThread && <Mail className="size-2.5" aria-label="From mail" />}
106
+ {zoneCertainty === "ambiguous" && (
107
+ <Globe
108
+ className="size-2.5 text-warning"
109
+ aria-label="Unclear zone"
110
+ />
111
+ )}
112
+ </span>
113
+ )}
114
+ </button>
115
+ );
116
+ }
@@ -0,0 +1,77 @@
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 { CalendarList } from "./calendar-list.js";
6
+ import type { CalendarDescriptor } from "./calendar-types.js";
7
+
8
+ const calendars: CalendarDescriptor[] = [
9
+ {
10
+ id: "c1",
11
+ accountId: "a1",
12
+ accountLabel: "Work",
13
+ name: "Northwind",
14
+ color: "cal-1",
15
+ },
16
+ {
17
+ id: "c2",
18
+ accountId: "a1",
19
+ accountLabel: "Work",
20
+ name: "On-call",
21
+ color: "cal-4",
22
+ },
23
+ {
24
+ id: "c3",
25
+ accountId: "a2",
26
+ accountLabel: "Personal",
27
+ name: "Family",
28
+ color: "cal-3",
29
+ },
30
+ ];
31
+
32
+ const render = (
33
+ visible: string[],
34
+ withAccountToggle = false,
35
+ closedAccountIds?: string[],
36
+ ) =>
37
+ renderToString(
38
+ createElement(CalendarList, {
39
+ calendars,
40
+ visible: new Set(visible),
41
+ onToggle: () => undefined,
42
+ ...(withAccountToggle ? { onToggleAccount: () => undefined } : {}),
43
+ ...(closedAccountIds ? { closedAccountIds } : {}),
44
+ }),
45
+ );
46
+
47
+ describe("CalendarList", () => {
48
+ it("groups calendars under the account that owns them", () => {
49
+ const html = render(["c1"]);
50
+ assert.match(html, /Work/);
51
+ assert.match(html, /Personal/);
52
+ assert.match(html, /Northwind/);
53
+ });
54
+
55
+ it("fills the swatch of a visible calendar and outlines a hidden one", () => {
56
+ const html = render(["c1"]);
57
+ assert.match(html, /bg-cal-1/);
58
+ assert.match(html, /bg-transparent/);
59
+ });
60
+
61
+ it("offers an all-or-none control per account only when asked", () => {
62
+ assert.doesNotMatch(render(["c1"]), />None</);
63
+ assert.match(render(["c1", "c2"], true), />None</);
64
+ });
65
+
66
+ it("offers 'All' when an account is fully hidden", () => {
67
+ assert.match(render([], true), />All</);
68
+ });
69
+
70
+ it("folds an account's rows away behind its caret, keeping the heading", () => {
71
+ const html = render(["c1", "c2", "c3"], true, ["a1"]);
72
+ assert.match(html, /aria-expanded="false"/);
73
+ assert.match(html, /Work/);
74
+ assert.doesNotMatch(html, /Northwind/);
75
+ assert.match(html, /Family/);
76
+ });
77
+ });
@@ -0,0 +1,130 @@
1
+ import type { Meta, StoryObj } from "@storybook/react-vite";
2
+ import { useState } from "react";
3
+ import { CalendarList } from "./calendar-list.js";
4
+ import type { CalendarDescriptor } from "./calendar-types.js";
5
+
6
+ /**
7
+ * The legend and the filter are the same control, and it never leaves the
8
+ * screen. Hiding it in a popover would mean reading a coloured grid with the
9
+ * key in another room.
10
+ */
11
+ const meta: Meta<typeof CalendarList> = {
12
+ title: "Calendar/Calendar list",
13
+ component: CalendarList,
14
+ parameters: { layout: "padded" },
15
+ decorators: [
16
+ (Story) => (
17
+ <div className="max-w-60 rounded-lg border border-line bg-surface py-2">
18
+ <Story />
19
+ </div>
20
+ ),
21
+ ],
22
+ };
23
+ export default meta;
24
+
25
+ type Story = StoryObj<typeof CalendarList>;
26
+
27
+ const calendars: CalendarDescriptor[] = [
28
+ {
29
+ id: "c1",
30
+ accountId: "a1",
31
+ accountLabel: "Work",
32
+ name: "Northwind",
33
+ color: "cal-1",
34
+ },
35
+ {
36
+ id: "c2",
37
+ accountId: "a1",
38
+ accountLabel: "Work",
39
+ name: "On-call",
40
+ color: "cal-4",
41
+ },
42
+ {
43
+ id: "c3",
44
+ accountId: "a2",
45
+ accountLabel: "Personal",
46
+ name: "Personal",
47
+ color: "cal-2",
48
+ },
49
+ {
50
+ id: "c4",
51
+ accountId: "a2",
52
+ accountLabel: "Personal",
53
+ name: "Family",
54
+ color: "cal-3",
55
+ },
56
+ {
57
+ id: "c5",
58
+ accountId: "a2",
59
+ accountLabel: "Personal",
60
+ name: "Travel",
61
+ color: "cal-6",
62
+ },
63
+ {
64
+ id: "c6",
65
+ accountId: "a3",
66
+ accountLabel: "Synthwave Forum",
67
+ name: "Synth meetups",
68
+ color: "cal-5",
69
+ },
70
+ ];
71
+
72
+ /** Ticking a calendar off is a first-class move, so it takes one click. */
73
+ export const Interactive: Story = {
74
+ render: () => {
75
+ const [visible, setVisible] = useState(
76
+ new Set(calendars.map((c) => c.id).filter((id) => id !== "c2")),
77
+ );
78
+ return (
79
+ <CalendarList
80
+ calendars={calendars}
81
+ visible={visible}
82
+ onToggle={(id) =>
83
+ setVisible((prev) => {
84
+ const next = new Set(prev);
85
+ if (!next.delete(id)) next.add(id);
86
+ return next;
87
+ })
88
+ }
89
+ onToggleAccount={(accountId, nextVisible) =>
90
+ setVisible((prev) => {
91
+ const next = new Set(prev);
92
+ for (const calendar of calendars) {
93
+ if (calendar.accountId !== accountId) continue;
94
+ if (nextVisible) next.add(calendar.id);
95
+ else next.delete(calendar.id);
96
+ }
97
+ return next;
98
+ })
99
+ }
100
+ />
101
+ );
102
+ },
103
+ };
104
+
105
+ /**
106
+ * An account folded shut. Its calendars are still on the grid — the caret hides
107
+ * rows, the tick hides events, and the two are not the same thing.
108
+ */
109
+ export const AccountFolded: Story = {
110
+ render: () => (
111
+ <CalendarList
112
+ calendars={calendars}
113
+ visible={new Set(calendars.map((c) => c.id))}
114
+ onToggle={() => {}}
115
+ onToggleAccount={() => {}}
116
+ closedAccountIds={["a2"]}
117
+ />
118
+ ),
119
+ };
120
+
121
+ /** Everything off: an unticked calendar keeps its swatch outline, so the key survives. */
122
+ export const AllHidden: Story = {
123
+ render: () => (
124
+ <CalendarList
125
+ calendars={calendars}
126
+ visible={new Set()}
127
+ onToggle={() => {}}
128
+ />
129
+ ),
130
+ };
@@ -0,0 +1,225 @@
1
+ import { Check, ChevronDown, ChevronRight } from "lucide-react";
2
+ import { useState } from "react";
3
+ import { calendarColorClasses } from "../lib/calendar-color.js";
4
+ import { cn } from "../lib/cn.js";
5
+ import type { CalendarDescriptor } from "./calendar-types.js";
6
+
7
+ export interface CalendarListProps {
8
+ calendars: CalendarDescriptor[];
9
+ /** Ids currently drawn on the grid. Anything absent is off. */
10
+ visible: ReadonlySet<string>;
11
+ onToggle: (calendarId: string) => void;
12
+ /** Ticks or clears a whole account in one go. */
13
+ onToggleAccount?: (accountId: string, nextVisible: boolean) => void;
14
+ /**
15
+ * `list` is the column: grouped by account, one row each. `strip` is the same
16
+ * control laid out as a scrolling row of chips, for a surface with no room
17
+ * for a rail. The control stays on screen either way — it never becomes a
18
+ * popover.
19
+ */
20
+ layout?: "list" | "strip";
21
+ /** Grows every row to a thumb target. */
22
+ touch?: boolean;
23
+ /** Accounts folded shut on mount. The list owns the state from there. */
24
+ closedAccountIds?: string[];
25
+ className?: string;
26
+ }
27
+
28
+ interface AccountGroup {
29
+ accountId: string;
30
+ accountLabel: string;
31
+ calendars: CalendarDescriptor[];
32
+ }
33
+
34
+ function groupByAccount(calendars: CalendarDescriptor[]): AccountGroup[] {
35
+ const order: string[] = [];
36
+ const byAccount = new Map<string, AccountGroup>();
37
+ for (const calendar of calendars) {
38
+ let group = byAccount.get(calendar.accountId);
39
+ if (!group) {
40
+ group = {
41
+ accountId: calendar.accountId,
42
+ accountLabel: calendar.accountLabel,
43
+ calendars: [],
44
+ };
45
+ byAccount.set(calendar.accountId, group);
46
+ order.push(calendar.accountId);
47
+ }
48
+ group.calendars.push(calendar);
49
+ }
50
+ return order.map((id) => byAccount.get(id) as AccountGroup);
51
+ }
52
+
53
+ /**
54
+ * The legend and the filter in one control, and always on screen. A calendar's
55
+ * colour means nothing unless the thing that names the colour is visible at the
56
+ * same time as the grid, so this never collapses into a popover or a settings
57
+ * page — turning a calendar off is a first-class move, not a preference.
58
+ *
59
+ * An account folds shut behind its own caret, the way a mailbox account folds in
60
+ * the nav. Folding is not filtering: a closed account's calendars stay on the
61
+ * grid, and the tick that takes them off is the one under the caret.
62
+ */
63
+ export function CalendarList({
64
+ calendars,
65
+ visible,
66
+ onToggle,
67
+ onToggleAccount,
68
+ layout = "list",
69
+ touch,
70
+ closedAccountIds,
71
+ className,
72
+ }: CalendarListProps) {
73
+ const [closed, setClosed] = useState<ReadonlySet<string>>(
74
+ () => new Set(closedAccountIds ?? []),
75
+ );
76
+ const toggleOpen = (accountId: string) =>
77
+ setClosed((prev) => {
78
+ const next = new Set(prev);
79
+ if (!next.delete(accountId)) next.add(accountId);
80
+ return next;
81
+ });
82
+
83
+ if (layout === "strip") {
84
+ return (
85
+ <div
86
+ className={cn(
87
+ "flex items-center gap-1.5 overflow-x-auto px-row-inset",
88
+ className,
89
+ )}
90
+ >
91
+ {calendars.map((calendar) => {
92
+ const hue = calendarColorClasses(calendar.color);
93
+ const checked = visible.has(calendar.id);
94
+ return (
95
+ <label
96
+ key={calendar.id}
97
+ className={cn(
98
+ "inline-flex shrink-0 cursor-pointer items-center gap-1.5 rounded-full border px-2.5 text-xs transition-colors",
99
+ "has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-ring",
100
+ touch ? "min-h-9" : "h-7",
101
+ checked
102
+ ? cn(hue.soft, hue.text, hue.border)
103
+ : "border-line text-fg-subtle",
104
+ )}
105
+ >
106
+ <input
107
+ type="checkbox"
108
+ checked={checked}
109
+ onChange={() => onToggle(calendar.id)}
110
+ className="sr-only"
111
+ />
112
+ <span
113
+ className={cn(
114
+ "size-2 rounded-full border",
115
+ hue.border,
116
+ checked ? hue.solid : "bg-transparent",
117
+ )}
118
+ />
119
+ {calendar.name}
120
+ </label>
121
+ );
122
+ })}
123
+ </div>
124
+ );
125
+ }
126
+
127
+ return (
128
+ <div className={cn("flex flex-col gap-3", className)}>
129
+ {groupByAccount(calendars).map((group) => {
130
+ const allOn = group.calendars.every((c) => visible.has(c.id));
131
+ const open = !closed.has(group.accountId);
132
+ return (
133
+ <section key={group.accountId} className="flex flex-col">
134
+ <div className="flex items-center gap-2 px-row-inset pb-1">
135
+ <button
136
+ type="button"
137
+ onClick={() => toggleOpen(group.accountId)}
138
+ aria-expanded={open}
139
+ className={cn(
140
+ "flex min-w-0 flex-1 items-center gap-1.5 rounded-sm text-left text-2xs font-semibold uppercase tracking-wider text-fg-subtle outline-none transition-colors hover:text-fg focus-visible:ring-2 focus-visible:ring-ring",
141
+ touch && "min-h-11",
142
+ )}
143
+ >
144
+ {open ? (
145
+ <ChevronDown className="size-3 shrink-0" />
146
+ ) : (
147
+ <ChevronRight className="size-3 shrink-0" />
148
+ )}
149
+ <span className="truncate">{group.accountLabel}</span>
150
+ </button>
151
+ {onToggleAccount && (
152
+ <button
153
+ type="button"
154
+ onClick={() => onToggleAccount(group.accountId, !allOn)}
155
+ className="rounded-sm text-2xs text-fg-subtle outline-none hover:text-fg focus-visible:ring-2 focus-visible:ring-ring"
156
+ >
157
+ {allOn ? "None" : "All"}
158
+ </button>
159
+ )}
160
+ </div>
161
+ {open &&
162
+ group.calendars.map((calendar) => (
163
+ <CalendarListRow
164
+ key={calendar.id}
165
+ calendar={calendar}
166
+ checked={visible.has(calendar.id)}
167
+ touch={touch}
168
+ onToggle={() => onToggle(calendar.id)}
169
+ />
170
+ ))}
171
+ </section>
172
+ );
173
+ })}
174
+ </div>
175
+ );
176
+ }
177
+
178
+ function CalendarListRow({
179
+ calendar,
180
+ checked,
181
+ touch,
182
+ onToggle,
183
+ }: {
184
+ calendar: CalendarDescriptor;
185
+ checked: boolean;
186
+ touch?: boolean;
187
+ onToggle: () => void;
188
+ }) {
189
+ const hue = calendarColorClasses(calendar.color);
190
+ return (
191
+ <label
192
+ className={cn(
193
+ "flex cursor-pointer items-center gap-2.5 rounded-sm px-row-inset hover:bg-surface-sunken",
194
+ touch ? "min-h-11" : "min-h-9",
195
+ )}
196
+ >
197
+ <input
198
+ type="checkbox"
199
+ checked={checked}
200
+ onChange={onToggle}
201
+ className="peer sr-only"
202
+ />
203
+ <span
204
+ aria-hidden
205
+ className={cn(
206
+ "flex size-4 shrink-0 items-center justify-center rounded-xs border transition-colors",
207
+ "peer-focus-visible:ring-2 peer-focus-visible:ring-ring peer-focus-visible:ring-offset-1 peer-focus-visible:ring-offset-surface",
208
+ checked
209
+ ? cn(hue.solid, hue.border)
210
+ : cn("bg-transparent", hue.border),
211
+ )}
212
+ >
213
+ {checked && <Check className="size-3 text-canvas" strokeWidth={3} />}
214
+ </span>
215
+ <span
216
+ className={cn(
217
+ "min-w-0 flex-1 truncate text-sm",
218
+ checked ? "text-fg" : "text-fg-subtle",
219
+ )}
220
+ >
221
+ {calendar.name}
222
+ </span>
223
+ </label>
224
+ );
225
+ }
@@ -0,0 +1,69 @@
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 { CalendarDateNav, CalendarViewSwitch } from "./calendar-toolbar.js";
6
+
7
+ describe("CalendarViewSwitch", () => {
8
+ it("offers the whole ladder by default", () => {
9
+ const html = renderToString(
10
+ createElement(CalendarViewSwitch, {
11
+ value: "week",
12
+ onChange: () => undefined,
13
+ }),
14
+ );
15
+ for (const label of ["Year", "Month", "Week", "Day", "Agenda"]) {
16
+ assert.match(html, new RegExp(label));
17
+ }
18
+ });
19
+
20
+ it("marks the current view", () => {
21
+ const html = renderToString(
22
+ createElement(CalendarViewSwitch, {
23
+ value: "day",
24
+ onChange: () => undefined,
25
+ }),
26
+ );
27
+ assert.match(html, /checked="" value="day"/);
28
+ });
29
+
30
+ it("narrows the ladder and shortens the labels for a thumb", () => {
31
+ const html = renderToString(
32
+ createElement(CalendarViewSwitch, {
33
+ value: "day",
34
+ onChange: () => undefined,
35
+ views: ["day", "agenda"],
36
+ touch: true,
37
+ }),
38
+ );
39
+ assert.doesNotMatch(html, /Year/);
40
+ assert.match(html, /min-h-11/);
41
+ });
42
+
43
+ it("marks the current view flat, without a raised surface", () => {
44
+ const html = renderToString(
45
+ createElement(CalendarViewSwitch, {
46
+ value: "week",
47
+ onChange: () => undefined,
48
+ }),
49
+ );
50
+ assert.match(html, /bg-accent-2-soft/);
51
+ assert.doesNotMatch(html, /shadow-sm/);
52
+ });
53
+ });
54
+
55
+ describe("CalendarDateNav", () => {
56
+ it("puts Today beside the range it returns to", () => {
57
+ const html = renderToString(
58
+ createElement(CalendarDateNav, {
59
+ title: "8 – 14 June 2026",
60
+ onPrev: () => undefined,
61
+ onNext: () => undefined,
62
+ onToday: () => undefined,
63
+ }),
64
+ );
65
+ assert.match(html, /Today/);
66
+ assert.match(html, /8 – 14 June 2026/);
67
+ assert.match(html, /aria-label="Previous"/);
68
+ });
69
+ });
@@ -0,0 +1,55 @@
1
+ import type { Meta, StoryObj } from "@storybook/react-vite";
2
+ import { useState } from "react";
3
+ import { CalendarDateNav, CalendarViewSwitch } from "./calendar-toolbar.js";
4
+ import type { CalendarViewId } from "./calendar-types.js";
5
+
6
+ /**
7
+ * The controls that sit above every calendar surface: the zoom ladder and one
8
+ * way home. The ladder is flat — the step you are on is marked by weight and
9
+ * hue, the same way the nav marks the mailbox you are in.
10
+ */
11
+ const meta: Meta = {
12
+ title: "Calendar/Toolbar",
13
+ parameters: { layout: "padded" },
14
+ };
15
+ export default meta;
16
+
17
+ type Story = StoryObj;
18
+
19
+ /** Year to day is one strip at four magnifications, not four screens. */
20
+ export const ViewLadder: Story = {
21
+ render: () => {
22
+ const [view, setView] = useState<CalendarViewId>("week");
23
+ return (
24
+ <div className="flex flex-col gap-3">
25
+ <CalendarViewSwitch value={view} onChange={setView} />
26
+ <CalendarViewSwitch
27
+ value={view}
28
+ onChange={setView}
29
+ views={["day", "agenda"]}
30
+ touch
31
+ />
32
+ <p className="text-xs text-fg-muted">Showing: {view}</p>
33
+ </div>
34
+ );
35
+ },
36
+ };
37
+
38
+ /** Today lands on the same target from every view and every distance. */
39
+ export const DateNav: Story = {
40
+ render: () => {
41
+ const [view, setView] = useState<CalendarViewId>("week");
42
+ return (
43
+ <div className="rounded-lg border border-line bg-surface p-2">
44
+ <CalendarDateNav
45
+ title="8 – 14 June 2026"
46
+ onPrev={() => {}}
47
+ onNext={() => {}}
48
+ onToday={() => {}}
49
+ >
50
+ <CalendarViewSwitch value={view} onChange={setView} />
51
+ </CalendarDateNav>
52
+ </div>
53
+ );
54
+ },
55
+ };