@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.
- package/package.json +3 -3
- package/src/components/app-shell-slotted.render.test.ts +83 -0
- package/src/components/app-shell-slotted.tsx +31 -3
- package/src/components/attendee-row.render.test.ts +73 -0
- package/src/components/attendee-row.stories.tsx +68 -0
- package/src/components/attendee-row.tsx +96 -0
- package/src/components/calendar-event-chip.render.test.ts +67 -0
- package/src/components/calendar-event-chip.stories.tsx +128 -0
- package/src/components/calendar-event-chip.tsx +116 -0
- package/src/components/calendar-list.render.test.ts +77 -0
- package/src/components/calendar-list.stories.tsx +130 -0
- package/src/components/calendar-list.tsx +225 -0
- package/src/components/calendar-toolbar.render.test.ts +69 -0
- package/src/components/calendar-toolbar.stories.tsx +55 -0
- package/src/components/calendar-toolbar.tsx +178 -0
- package/src/components/calendar-types.ts +135 -0
- package/src/components/custom-recurrence.stories.tsx +106 -0
- package/src/components/custom-recurrence.tsx +411 -0
- package/src/components/event-detail.render.test.ts +104 -0
- package/src/components/event-detail.stories.tsx +178 -0
- package/src/components/event-detail.tsx +188 -0
- package/src/components/event-editor-pane.tsx +54 -0
- package/src/components/event-editor.render.test.ts +83 -0
- package/src/components/event-editor.stories.tsx +99 -0
- package/src/components/event-editor.tsx +480 -0
- package/src/components/event-quick-entry.render.test.ts +40 -0
- package/src/components/event-quick-entry.stories.tsx +56 -0
- package/src/components/event-quick-entry.tsx +159 -0
- package/src/components/event-suggestion-card.render.test.ts +65 -0
- package/src/components/event-suggestion-card.stories.tsx +93 -0
- package/src/components/event-suggestion-card.tsx +116 -0
- package/src/components/flow-screen.tsx +169 -0
- package/src/components/intelligence-panel.stories.tsx +5 -1
- package/src/components/intelligence-panel.tsx +4 -0
- package/src/components/isolated-email-frame.tsx +1 -18
- package/src/components/message-list-pane.render.test.ts +2 -2
- package/src/components/message-list-pane.stories.tsx +1 -1
- package/src/components/nav-sidebar.tsx +20 -0
- package/src/components/popover-menu.tsx +230 -27
- package/src/components/pull-to-refresh.tsx +1 -19
- package/src/components/recurrence-scope-prompt.render.test.ts +36 -0
- package/src/components/recurrence-scope-prompt.stories.tsx +46 -0
- package/src/components/recurrence-scope-prompt.tsx +84 -0
- package/src/components/rich-text-correction-menu.tsx +220 -0
- package/src/components/rich-text-editor.stories.tsx +507 -5
- package/src/components/rich-text-editor.tsx +482 -83
- package/src/components/rich-text-spellcheck-menu.test.ts +865 -0
- package/src/components/rich-text-spellcheck-provider.test.ts +114 -1
- package/src/components/rich-text-spellcheck-provider.ts +68 -3
- package/src/components/rich-text-spellcheck-words.ts +168 -4
- package/src/components/rich-text-spellcheck-worker.ts +11 -0
- package/src/components/rich-text-spellcheck.test.ts +7 -0
- package/src/components/rich-text-spellcheck.ts +28 -2
- package/src/components/selection-wizard.tsx +19 -69
- package/src/index.ts +116 -0
- package/src/lib/calendar-color.ts +71 -0
- package/src/lib/event-phrase.test.ts +89 -0
- package/src/lib/event-phrase.ts +228 -0
- package/src/lib/recurrence.test.ts +141 -0
- package/src/lib/recurrence.ts +266 -0
- package/src/lib/use-match-media.ts +25 -0
- package/src/rich-text.ts +12 -0
- 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
|
+
};
|