@rapidmx/web-client 0.23.0 → 0.25.1
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/apps/admin/mailboxes/[uid].tsx +2 -1
- package/apps/shared/components/admin/layout/AdminShell.tsx +39 -4
- package/apps/shared/components/admin/layout/railPreference.ts +25 -0
- package/apps/shared/components/admin/settings/MailboxCreateForm.tsx +2 -1
- package/apps/shared/components/calendar/AppointmentQuickForm.tsx +2 -1
- package/apps/shared/components/calendar/CalendarListSidebar.tsx +6 -2
- package/apps/shared/components/calendar/EventDescriptionView.tsx +37 -5
- package/apps/shared/components/calendar/EventDetails.tsx +15 -6
- package/apps/shared/components/calendar/EventEditor.tsx +5 -2
- package/apps/shared/components/calendar/EventFormParts.tsx +45 -39
- package/apps/shared/components/calendar/EventQuickForm.tsx +3 -2
- package/apps/shared/components/calendar/FindATime.tsx +3 -2
- package/apps/shared/components/calendar/TimeComboBox.tsx +143 -0
- package/apps/shared/components/calendar/eventFormat.ts +15 -0
- package/apps/shared/components/calendar/timePicker.ts +106 -0
- package/apps/shared/components/layout/ThemeSwitch.tsx +3 -2
- package/apps/shared/components/layout/UserMenu.tsx +30 -10
- package/apps/shared/components/mail/invite/inviteFormat.ts +6 -5
- package/apps/shared/components/mail/layout/MailShell.tsx +13 -5
- package/apps/shared/components/mail/layout/folderIcons.tsx +54 -0
- package/apps/shared/components/settings/layout/SettingsShell.tsx +32 -13
- package/apps/shared/navigation/appHrefs.ts +3 -3
- package/apps/www/calendar/index.tsx +5 -2
- package/apps/www/settings/profile/index.tsx +8 -12
- package/dist/apps/admin/mailboxes/[uid].js +2 -1
- package/dist/apps/shared/components/admin/layout/AdminShell.js +21 -4
- package/dist/apps/shared/components/admin/layout/railPreference.d.ts +6 -0
- package/dist/apps/shared/components/admin/layout/railPreference.js +24 -0
- package/dist/apps/shared/components/admin/settings/MailboxCreateForm.js +2 -2
- package/dist/apps/shared/components/calendar/AppointmentQuickForm.js +2 -1
- package/dist/apps/shared/components/calendar/CalendarListSidebar.js +3 -2
- package/dist/apps/shared/components/calendar/EventDescriptionView.d.ts +6 -1
- package/dist/apps/shared/components/calendar/EventDescriptionView.js +34 -4
- package/dist/apps/shared/components/calendar/EventDetails.js +4 -4
- package/dist/apps/shared/components/calendar/EventEditor.js +5 -2
- package/dist/apps/shared/components/calendar/EventFormParts.d.ts +1 -1
- package/dist/apps/shared/components/calendar/EventFormParts.js +9 -3
- package/dist/apps/shared/components/calendar/EventQuickForm.js +3 -2
- package/dist/apps/shared/components/calendar/FindATime.js +3 -2
- package/dist/apps/shared/components/calendar/TimeComboBox.d.ts +25 -0
- package/dist/apps/shared/components/calendar/TimeComboBox.js +77 -0
- package/dist/apps/shared/components/calendar/eventFormat.d.ts +4 -0
- package/dist/apps/shared/components/calendar/eventFormat.js +13 -0
- package/dist/apps/shared/components/calendar/timePicker.d.ts +31 -0
- package/dist/apps/shared/components/calendar/timePicker.js +89 -0
- package/dist/apps/shared/components/layout/ThemeSwitch.js +2 -2
- package/dist/apps/shared/components/layout/UserMenu.d.ts +1 -2
- package/dist/apps/shared/components/layout/UserMenu.js +4 -4
- package/dist/apps/shared/components/mail/invite/inviteFormat.js +6 -5
- package/dist/apps/shared/components/mail/layout/MailShell.js +5 -4
- package/dist/apps/shared/components/mail/layout/folderIcons.d.ts +8 -0
- package/dist/apps/shared/components/mail/layout/folderIcons.js +25 -0
- package/dist/apps/shared/components/settings/layout/SettingsShell.d.ts +3 -0
- package/dist/apps/shared/components/settings/layout/SettingsShell.js +16 -15
- package/dist/apps/shared/navigation/appHrefs.d.ts +3 -3
- package/dist/apps/shared/navigation/appHrefs.js +3 -3
- package/dist/apps/www/calendar/index.js +5 -2
- package/dist/apps/www/settings/profile/index.js +6 -5
- package/dist/lib/auth/session.d.ts +14 -1
- package/dist/lib/auth/session.js +40 -3
- package/dist/lib/components/pickers/TimeZonePicker.d.ts +20 -0
- package/dist/lib/components/pickers/TimeZonePicker.js +109 -0
- package/dist/lib/util/api.d.ts +6 -0
- package/dist/lib/util/api.js +22 -1
- package/dist/lib/util/timeZone.d.ts +19 -1
- package/dist/lib/util/timeZone.js +59 -2
- package/lib/auth/session.ts +48 -3
- package/lib/components/pickers/TimeZonePicker.tsx +185 -0
- package/lib/util/api.ts +24 -1
- package/lib/util/timeZone.ts +65 -2
- package/package.json +1 -1
|
@@ -6,7 +6,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
6
6
|
import "../../../styles/app.css";
|
|
7
7
|
import { useEffect, useState } from "react";
|
|
8
8
|
import { useRouter } from "@rapidrest/react/client";
|
|
9
|
-
import { HiOutlineClipboardDocumentList, HiOutlineClock, HiOutlineCpuChip, HiOutlineDocumentArrowDown, HiOutlineDocumentCheck, HiOutlineGlobeAlt, HiOutlineInboxStack, HiOutlineKey, HiOutlineLockClosed, HiOutlinePaintBrush, HiOutlinePuzzlePiece, HiOutlineQueueList, HiOutlineRocketLaunch, HiOutlineShieldCheck, HiOutlineShieldExclamation, HiOutlineUserGroup, HiOutlineWrenchScrewdriver, HiOutlineBars3, } from "react-icons/hi2";
|
|
9
|
+
import { HiOutlineClipboardDocumentList, HiOutlineClock, HiOutlineCpuChip, HiOutlineDocumentArrowDown, HiOutlineDocumentCheck, HiOutlineGlobeAlt, HiOutlineInboxStack, HiOutlineKey, HiOutlineLockClosed, HiOutlinePaintBrush, HiOutlinePuzzlePiece, HiOutlineQueueList, HiOutlineRocketLaunch, HiOutlineShieldCheck, HiOutlineShieldExclamation, HiOutlineUserGroup, HiOutlineWrenchScrewdriver, HiOutlineBars3, HiOutlineChevronDoubleLeft, HiOutlineChevronDoubleRight, } from "react-icons/hi2";
|
|
10
10
|
import { apiFetch, ApiRequestError } from "../../../../../lib/util/api.js";
|
|
11
11
|
import { getSetupStatus } from "../../../../../lib/admin/setupApi.js";
|
|
12
12
|
import { useSessionRefresh } from "../../../../../lib/auth/session.js";
|
|
@@ -16,6 +16,7 @@ import Button from "../../../../../lib/components/buttons/Button.js";
|
|
|
16
16
|
import Drawer from "../../../../../lib/components/overlays/Drawer.js";
|
|
17
17
|
import { FrameBrandingFooter, useBrandingHtml } from "../../layout/BrandingChrome.js";
|
|
18
18
|
import RailIcon from "../../layout/RailIcon.js";
|
|
19
|
+
import { readRailExpanded, writeRailExpanded } from "./railPreference.js";
|
|
19
20
|
import AppearanceProvider from "../../../appearance/AppearanceProvider.js";
|
|
20
21
|
import UserMenu from "../../layout/UserMenu.js";
|
|
21
22
|
import { clearElevationAttempt, elevationAttemptedRecently, elevationUrl, isElevationRequired, recordElevationAttempt, } from "../elevation.js";
|
|
@@ -114,6 +115,15 @@ export default function AdminShell({ active, userUid, authServerUrl, pluginNav,
|
|
|
114
115
|
const [status, setStatus] = useState("checking");
|
|
115
116
|
const [error, setError] = useState(null);
|
|
116
117
|
const [menuOpen, setMenuOpen] = useState(false);
|
|
118
|
+
// The side rail shows icons alone, or icons with their labels. Read after the first render: the page is drawn on the server first, which cannot know.
|
|
119
|
+
const [railExpanded, setRailExpanded] = useState(false);
|
|
120
|
+
useEffect(() => setRailExpanded(readRailExpanded()), []);
|
|
121
|
+
function toggleRail() {
|
|
122
|
+
setRailExpanded((expanded) => {
|
|
123
|
+
writeRailExpanded(!expanded);
|
|
124
|
+
return !expanded;
|
|
125
|
+
});
|
|
126
|
+
}
|
|
117
127
|
// `branding` only feeds the footer below: the admin-configured header is for the webmail and public pages, not the
|
|
118
128
|
// console. `useBranding()` is still what injects the custom stylesheet and supplies the rail's icon.
|
|
119
129
|
const { branding, iconSrc } = useBranding();
|
|
@@ -198,12 +208,19 @@ export default function AdminShell({ active, userUid, authServerUrl, pluginNav,
|
|
|
198
208
|
else {
|
|
199
209
|
const navItems = adminNavItems(pluginNav);
|
|
200
210
|
const activeItem = [...navItems, ...OFF_RAIL_ITEMS].find((item) => item.id === active);
|
|
201
|
-
content = (_jsx("div", { className: "rr-frame-bg min-h-screen flex flex-col", children: _jsxs("div", { className: "flex-1 flex min-h-0", children: [_jsxs("nav", { "aria-label": "Admin sections", className:
|
|
202
|
-
|
|
211
|
+
content = (_jsx("div", { className: "rr-frame-bg min-h-screen flex flex-col", children: _jsxs("div", { className: "flex-1 flex min-h-0", children: [_jsxs("nav", { "aria-label": "Admin sections", className: [
|
|
212
|
+
"hidden md:flex shrink-0 bg-surface border-r border-border flex-col pb-3 gap-1",
|
|
213
|
+
railExpanded ? "w-56 items-stretch" : "w-16 items-center",
|
|
214
|
+
].join(" "), children: [_jsx(RailIcon, { src: iconSrc }), navItems.map(({ id, href, label, icon: Icon }) => (_jsxs("a", { href: href, "aria-label": label, "aria-current": id === active ? "page" : undefined, title: railExpanded ? undefined : label, className: [
|
|
215
|
+
"h-10 flex items-center rounded-sm",
|
|
216
|
+
railExpanded ? "gap-3 px-3 mx-2 text-sm font-medium" : "w-10 justify-center",
|
|
203
217
|
id === active
|
|
204
218
|
? "bg-primary/10 text-primary-dark"
|
|
205
219
|
: "text-text-muted hover:bg-surface-alt hover:text-text",
|
|
206
|
-
].join(" "), children: _jsx(Icon, { size: 20, "aria-hidden": "true"
|
|
220
|
+
].join(" "), children: [_jsx(Icon, { size: 20, "aria-hidden": "true", className: "shrink-0" }), railExpanded && _jsx("span", { className: "truncate", children: label })] }, id))), _jsxs("button", { type: "button", "aria-expanded": railExpanded, "aria-label": railExpanded ? "Collapse the menu" : "Expand the menu", title: railExpanded ? "Collapse the menu" : "Expand the menu", onClick: toggleRail, className: [
|
|
221
|
+
"mt-auto h-10 flex items-center rounded-sm text-text-muted hover:bg-surface-alt hover:text-text",
|
|
222
|
+
railExpanded ? "gap-3 px-3 mx-2 text-sm font-medium" : "w-10 justify-center",
|
|
223
|
+
].join(" "), children: [railExpanded ? (_jsx(HiOutlineChevronDoubleLeft, { size: 20, "aria-hidden": "true", className: "shrink-0" })) : (_jsx(HiOutlineChevronDoubleRight, { size: 20, "aria-hidden": "true", className: "shrink-0" })), railExpanded && _jsx("span", { children: "Collapse" })] })] }), _jsx(Drawer, { open: menuOpen, onClose: () => setMenuOpen(false), title: "Admin", fullScreen: true, children: _jsx("nav", { "aria-label": "Admin menu", className: "flex flex-col gap-1 -mx-2", children: navItems.map(({ id, href, label, icon: Icon }) => (_jsxs("a", { href: href, "aria-current": id === active ? "page" : undefined, onClick: () => setMenuOpen(false), className: [
|
|
207
224
|
"flex items-center gap-3 px-3 py-2.5 rounded-sm text-sm font-medium",
|
|
208
225
|
id === active ? "bg-primary/10 text-primary-dark" : "text-text hover:bg-surface-alt",
|
|
209
226
|
].join(" "), children: [_jsx(Icon, { size: 20, "aria-hidden": "true" }), label] }, id))) }) }), _jsxs("div", { className: "flex-1 flex flex-col min-w-0", children: [_jsxs("header", { className: "rr-solid sticky top-0 z-30 h-16 shrink-0 bg-surface border-b border-border flex items-center justify-between gap-4 px-4 md:px-6", children: [_jsxs("div", { className: "flex items-center gap-2 min-w-0", children: [_jsx("button", { type: "button", className: "md:hidden shrink-0 w-9 h-9 flex items-center justify-center rounded-sm text-text-muted hover:bg-surface-alt hover:text-text", "aria-label": "Open menu", onClick: () => setMenuOpen(true), children: _jsx(HiOutlineBars3, { size: 20, "aria-hidden": "true" }) }), _jsx("span", { className: "font-display font-bold text-lg uppercase tracking-wide truncate", children: activeItem?.label })] }), _jsx(UserMenu, { userUid: userUid, authServerUrl: authServerUrl, onSignOut: handleSignOut, showMailLink: true })] }), _jsx("div", { id: "app-content", className: "flex-1", children: _jsx("main", { className: "max-w-6xl mx-auto px-6 py-8", children: children }) })] })] }) }));
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/** Where this browser remembers whether the admin console's side rail is showing its labels. */
|
|
2
|
+
export declare const RAIL_EXPANDED_KEY = "rapidmx:admin-rail-expanded";
|
|
3
|
+
/** Whether the rail was left expanded. A missing or blocked store means collapsed, as it always was. */
|
|
4
|
+
export declare function readRailExpanded(): boolean;
|
|
5
|
+
/** Remembers the rail's state. Best effort: a blocked store only means it starts collapsed the next time. */
|
|
6
|
+
export declare function writeRailExpanded(expanded: boolean): void;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
/** Where this browser remembers whether the admin console's side rail is showing its labels. */
|
|
6
|
+
export const RAIL_EXPANDED_KEY = "rapidmx:admin-rail-expanded";
|
|
7
|
+
/** Whether the rail was left expanded. A missing or blocked store means collapsed, as it always was. */
|
|
8
|
+
export function readRailExpanded() {
|
|
9
|
+
try {
|
|
10
|
+
return localStorage.getItem(RAIL_EXPANDED_KEY) === "true";
|
|
11
|
+
}
|
|
12
|
+
catch {
|
|
13
|
+
return false;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
/** Remembers the rail's state. Best effort: a blocked store only means it starts collapsed the next time. */
|
|
17
|
+
export function writeRailExpanded(expanded) {
|
|
18
|
+
try {
|
|
19
|
+
localStorage.setItem(RAIL_EXPANDED_KEY, String(expanded));
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
// Nothing to do.
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -8,7 +8,7 @@ import { ApiRequestError } from "../../../../../lib/util/api.js";
|
|
|
8
8
|
import { getMailboxPolicy } from "../../../../../lib/admin/mailboxPolicyApi.js";
|
|
9
9
|
import { leftoverConflictOf } from "../../../../../lib/admin/leftoverMailboxApi.js";
|
|
10
10
|
import { createMailbox, listMailboxDomains, resolveMailboxOwner } from "../../../../../lib/mail/mailApi.js";
|
|
11
|
-
import { deviceTimeZone } from "../../../../../lib/util/timeZone.js";
|
|
11
|
+
import { describeTimeZone, deviceTimeZone, zoneClock } from "../../../../../lib/util/timeZone.js";
|
|
12
12
|
import { useApiClient } from "../../../../../lib/util/apiClientContext.js";
|
|
13
13
|
import Alert from "../../../../../lib/components/feedback/Alert.js";
|
|
14
14
|
import Button from "../../../../../lib/components/buttons/Button.js";
|
|
@@ -142,7 +142,7 @@ export default function MailboxCreateForm({ onCreated, defaults, submitLabel = "
|
|
|
142
142
|
} }), "Shared mailbox (no single owner)"] }), _jsxs("label", { className: "flex items-center gap-2 text-sm", children: [_jsx("input", { type: "radio", name: "ownerMode", checked: ownerMode === "owned", onChange: () => setOwnerMode("owned") }), "Owned by a specific person"] }), ownerMode === "owned" &&
|
|
143
143
|
(resolvedOwner ? (_jsxs("div", { className: "flex items-center justify-between gap-3 text-sm py-2 px-3 border border-border rounded-sm", children: [_jsxs("span", { children: ["Owner: ", _jsx("strong", { className: "break-all", children: describePerson(resolvedOwner) })] }), _jsx(Button, { type: "button", variant: "text", className: "!w-auto", onClick: () => setResolvedOwner(null), children: "Change" })] })) : (_jsx(PrincipalResolver, { resolve: (principal) => resolveMailboxOwner(principal, client), onResolved: (person) => {
|
|
144
144
|
setResolvedOwner(person);
|
|
145
|
-
}, initialPrincipal: defaults?.ownerUserUid, placeholder: "Email address, username or user id", ariaLabel: "Mailbox owner", confirmLabel: "Use this owner", confirmErrorMessage: "Could not confirm this person.", describeConfirm: (person) => (_jsxs(_Fragment, { children: ["Make ", _jsx("strong", { className: "break-all", children: describePerson(person) }), " the owner?"] })) })))] })] }),
|
|
145
|
+
}, initialPrincipal: defaults?.ownerUserUid, placeholder: "Email address, username or user id", ariaLabel: "Mailbox owner", confirmLabel: "Use this owner", confirmErrorMessage: "Could not confirm this person.", describeConfirm: (person) => (_jsxs(_Fragment, { children: ["Make ", _jsx("strong", { className: "break-all", children: describePerson(person) }), " the owner?"] })) })))] })] }), _jsxs(FormField, { label: "Timezone", htmlFor: "timezone", children: [_jsx("input", { id: "timezone", type: "text", className: INPUT_CLASS, value: timezone, onChange: (e) => setTimezone(e.target.value), placeholder: "America/Los_Angeles" }), zoneClock(timezone) && _jsx("p", { className: "text-xs text-text-muted mt-1", children: describeTimeZone(timezone) })] }), _jsx(FormField, { label: "Quota (GB)", htmlFor: "quotaGb", children: _jsx("input", { id: "quotaGb", type: "number", min: 1, step: 1, className: INPUT_CLASS, value: quotaGb, onChange: (e) => {
|
|
146
146
|
quotaTouched.current = true;
|
|
147
147
|
setQuotaGb(Number(e.target.value));
|
|
148
148
|
} }) }), _jsxs("label", { className: "flex items-center gap-2 text-sm mb-4", children: [_jsx("input", { type: "checkbox", checked: isResource, onChange: (e) => setIsResource(e.target.checked) }), "This is a resource mailbox (a bookable room or piece of equipment)"] }), isResource && (_jsxs("div", { className: "border border-border rounded-sm p-4 mb-4 flex flex-col gap-4", children: [_jsx(FormField, { label: "Resource type", htmlFor: "resourceType", children: _jsxs("select", { id: "resourceType", className: SELECT_CLASS, value: resourceType, onChange: (e) => setResourceType(e.target.value), children: [_jsx("option", { value: "room", children: "Room" }), _jsx("option", { value: "equipment", children: "Equipment" })] }) }), _jsx(FormField, { label: "Capacity (optional)", htmlFor: "resourceCapacity", children: _jsx("input", { id: "resourceCapacity", type: "number", min: 0, step: 1, className: INPUT_CLASS, value: resourceCapacity, onChange: (e) => setResourceCapacity(e.target.value), placeholder: "Informational only" }) }), _jsxs("label", { className: "flex items-center gap-2 text-sm", children: [_jsx("input", { type: "checkbox", checked: autoAcceptBookings, onChange: (e) => setAutoAcceptBookings(e.target.checked) }), "Automatically accept booking requests"] }), _jsxs("label", { className: "flex items-center gap-2 text-sm", children: [_jsx("input", { type: "checkbox", checked: allowConflicts, onChange: (e) => setAllowConflicts(e.target.checked) }), "Allow conflicting bookings (skip conflict checking entirely)"] }), _jsx(FormField, { label: "Booking window, in days (optional)", htmlFor: "bookingWindowDays", children: _jsx("input", { id: "bookingWindowDays", type: "number", min: 0, step: 1, className: INPUT_CLASS, value: bookingWindowDays, onChange: (e) => setBookingWindowDays(e.target.value), placeholder: "No limit" }) }), _jsx(FormField, { label: "Maximum duration, in minutes (optional)", htmlFor: "maxDurationMinutes", children: _jsx("input", { id: "maxDurationMinutes", type: "number", min: 0, step: 1, className: INPUT_CLASS, value: maxDurationMinutes, onChange: (e) => setMaxDurationMinutes(e.target.value), placeholder: "No limit" }) })] })), _jsxs("div", { className: "flex gap-3 mt-2", children: [_jsx(Button, { type: "submit", loading: saving, disabled: saving, className: "!w-auto", children: submitLabel }), cancelHref && (_jsx("a", { href: cancelHref, children: _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", children: "Cancel" }) }))] })] })] }));
|
|
@@ -11,6 +11,7 @@ import CopyButton from "../../../../lib/components/buttons/CopyButton.js";
|
|
|
11
11
|
import { bookingPublicUrl, createBookingType, newBookingLinkHref, slugFor, } from "../../calendar/bookingPlugin.js";
|
|
12
12
|
import { CalendarField, INPUT_CLASS, IconRow, SELECT_CLASS } from "./EventFormParts.js";
|
|
13
13
|
import QuickFaceFrame from "./QuickFaceFrame.js";
|
|
14
|
+
import { describeTimeZone } from "../../../../lib/util/timeZone.js";
|
|
14
15
|
const DAY_NAMES = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
|
|
15
16
|
const DURATIONS = ["15", "30", "45", "60"];
|
|
16
17
|
const BUFFERS = ["0", "5", "10", "15", "30"];
|
|
@@ -131,5 +132,5 @@ export default function AppointmentQuickForm({ c, draft, onDraftChange, tabs, se
|
|
|
131
132
|
return (_jsx(QuickFaceFrame, { c: c, tabs: tabs, tab: "appointment", error: error, hideTitle: !!created, onSubmit: (event) => void handleSubmit(event), footer: created ? (_jsx(Button, { type: "submit", className: "!w-auto", children: "Done" })) : (_jsxs(_Fragment, { children: [_jsxs("a", { href: newBookingLinkHref(settingsHref, values.targetMailboxUid), className: "inline-flex items-center gap-1 text-sm font-semibold text-accent-dark hover:underline px-1 py-1", children: ["More options", _jsx(HiOutlineArrowTopRightOnSquare, { size: 14, "aria-hidden": "true" })] }), _jsx(Button, { type: "submit", loading: saving, disabled: saving, className: "!w-auto", children: "Save" })] })), children: created ? (_jsxs(IconRow, { icon: _jsx(HiOutlineLink, { size: 20 }), children: [_jsxs("p", { role: "status", className: "text-sm font-medium mb-1", children: ["\u201C", created.name, "\u201D is ready to book."] }), _jsx("p", { className: "text-xs text-text-muted mb-2", children: "Share this link and anyone can pick an open time - no account needed." }), _jsxs("div", { className: "flex items-center gap-2", children: [_jsx("input", { type: "text", readOnly: true, "aria-label": "Booking link", className: `${INPUT_CLASS} min-w-0`, value: created.url, onFocus: (e) => e.target.select() }), _jsx(CopyButton, { value: created.url, label: "Copy the booking link" })] }), _jsx("a", { href: `${settingsHref}/${encodeURIComponent(created.uid)}?mailboxUid=${encodeURIComponent(created.mailboxUid)}`, className: "inline-block text-xs text-primary-dark hover:underline mt-2", children: "Change its settings" })] })) : (_jsxs(_Fragment, { children: [_jsx(IconRow, { icon: _jsx(HiOutlineClock, { size: 20 }), children: _jsxs("div", { className: "flex flex-wrap items-center gap-2 text-sm", children: [_jsxs("select", { "aria-label": "Duration", className: SELECT_CLASS, value: draft.duration, onChange: (e) => onDraftChange({ duration: e.target.value }), children: [DURATIONS.map((minutes) => (_jsxs("option", { value: minutes, children: [minutes, " minutes"] }, minutes))), _jsx("option", { value: "custom", children: "Custom" })] }), draft.duration === "custom" && (_jsxs(_Fragment, { children: [_jsx("input", { type: "number", min: MIN_DURATION, max: MAX_DURATION, "aria-label": "Custom duration in minutes", className: `${INPUT_CLASS} !w-24`, value: draft.customMinutes, onChange: (e) => onDraftChange({ customMinutes: e.target.value }) }), _jsx("span", { className: "text-text-muted", children: "minutes" })] })), _jsx("span", { className: "text-text-muted", children: "with" }), _jsx("select", { "aria-label": "Buffer after each appointment", className: SELECT_CLASS, value: draft.buffer, onChange: (e) => onDraftChange({ buffer: e.target.value }), children: BUFFERS.map((minutes) => (_jsx("option", { value: minutes, children: minutes === "0" ? "no buffer" : `${minutes} min buffer after` }, minutes))) })] }) }), _jsxs(IconRow, { icon: _jsx(HiOutlineCalendarDays, { size: 20 }), children: [_jsx("div", { role: "group", "aria-label": "Days people can book", className: "flex gap-1 mb-2", children: DAY_NAMES.map((day, index) => (_jsx("button", { type: "button", "aria-label": day, "aria-pressed": draft.days[index], title: day, onClick: () => onDraftChange({ days: draft.days.map((open, i) => (i === index ? !open : open)) }), className: [
|
|
132
133
|
"w-8 h-8 rounded-full text-xs font-medium border",
|
|
133
134
|
draft.days[index] ? "bg-primary text-white border-primary" : "border-border text-text-muted hover:bg-surface-alt",
|
|
134
|
-
].join(" "), children: day[0] }, day))) }), _jsxs("div", { className: "flex flex-wrap items-center gap-2 text-sm", children: [_jsx("input", { type: "time", "aria-label": "Bookable from", className: `${SELECT_CLASS} min-w-0`, value: draft.startTime, onChange: (e) => onDraftChange({ startTime: e.target.value }) }), _jsx("span", { className: "text-text-muted", children: "to" }), _jsx("input", { type: "time", "aria-label": "Bookable until", className: `${SELECT_CLASS} min-w-0`, value: draft.endTime, onChange: (e) => onDraftChange({ endTime: e.target.value }) })] }), _jsxs("p", { className: "text-xs text-text-muted mt-1", children: ["Times are in ", timeZone
|
|
135
|
+
].join(" "), children: day[0] }, day))) }), _jsxs("div", { className: "flex flex-wrap items-center gap-2 text-sm", children: [_jsx("input", { type: "time", "aria-label": "Bookable from", className: `${SELECT_CLASS} min-w-0`, value: draft.startTime, onChange: (e) => onDraftChange({ startTime: e.target.value }) }), _jsx("span", { className: "text-text-muted", children: "to" }), _jsx("input", { type: "time", "aria-label": "Bookable until", className: `${SELECT_CLASS} min-w-0`, value: draft.endTime, onChange: (e) => onDraftChange({ endTime: e.target.value }) })] }), _jsxs("p", { className: "text-xs text-text-muted mt-1", children: ["Times are in ", describeTimeZone(timeZone), "."] })] }), _jsx(IconRow, { icon: _jsx(HiOutlineMapPin, { size: 20 }), children: _jsx("div", { role: "group", "aria-label": "How people can meet", className: "flex flex-wrap gap-x-4 gap-y-1 text-sm py-1", children: LOCATIONS.map((location) => (_jsxs("label", { className: "flex items-center gap-2", children: [_jsx("input", { type: "checkbox", checked: draft[location.key], onChange: (e) => onDraftChange({ [location.key]: e.target.checked }) }), location.label] }, location.key))) }) }), _jsxs(IconRow, { icon: _jsx(HiOutlineCalendar, { size: 20 }), children: [_jsx("p", { className: "text-xs text-text-muted mb-1", children: "Bookings are added to" }), _jsx(CalendarField, { c: c })] })] })) }));
|
|
135
136
|
}
|
|
@@ -7,6 +7,7 @@ import { useState } from "react";
|
|
|
7
7
|
import { CALENDAR_COLOR_PALETTE, accentColorForMailbox, colorForFolder } from "../../../../lib/calendar/calendarColors.js";
|
|
8
8
|
import Alert from "../../../../lib/components/feedback/Alert.js";
|
|
9
9
|
import { notifyApiError } from "../../notifications/apiErrors.js";
|
|
10
|
+
import { SharedMailboxMark } from "../mail/layout/folderIcons.js";
|
|
10
11
|
/**
|
|
11
12
|
* Calendar's left-sidebar checklist of calendars — one row per `calendar`-type folder, a color swatch,
|
|
12
13
|
* and a checkbox controlling whether that calendar's events are currently shown (the checked set is owned
|
|
@@ -49,7 +50,7 @@ export default function CalendarListSidebar({ mailboxCalendars, checkedFolderUid
|
|
|
49
50
|
}
|
|
50
51
|
const addForm = (_jsxs("form", { onSubmit: handleAdd, className: "flex flex-col gap-1.5 mt-2", children: [_jsx("input", { type: "text", autoFocus: true, "aria-label": "New calendar name", value: name, onChange: (e) => setName(e.target.value), className: "text-xs py-1 px-1.5 border border-border rounded-sm bg-surface" }), _jsx("div", { className: "flex items-center gap-1", role: "radiogroup", "aria-label": "Calendar color", children: CALENDAR_COLOR_PALETTE.map((swatch) => (_jsx("button", { type: "button", "aria-label": `Color ${swatch}`, "aria-pressed": color === swatch, onClick: () => setColor(swatch), className: ["w-4 h-4 rounded-full shrink-0", color === swatch ? "ring-2 ring-offset-1 ring-text" : ""].join(" "), style: { backgroundColor: swatch } }, swatch))) }), _jsxs("div", { className: "flex items-center gap-2", children: [_jsx("button", { type: "submit", disabled: saving, className: "text-xs font-semibold text-primary-dark", children: "Add" }), _jsx("button", { type: "button", onClick: () => setAddingFor(null), className: "text-xs text-text-muted", children: "Cancel" })] })] }));
|
|
51
52
|
return (_jsx("nav", { "aria-label": "My calendars", className: "p-3 border-t border-border flex flex-col gap-3", children: mailboxCalendars.map(({ mailbox, calendarFolders, error: mailboxError }) => {
|
|
52
|
-
const title = multiple ?
|
|
53
|
-
return (_jsxs("div", { children: [_jsxs("div", { className: "flex items-center justify-between mb-1 gap-2", children: [_jsxs("h2", { className: "flex items-center gap-1.5 text-xs font-bold uppercase tracking-wide text-text-muted min-w-0", children: [multiple && (_jsx("span", { "aria-hidden": "true", className: "w-2 h-2 rounded-full shrink-0", style: { backgroundColor: accentColorForMailbox(mailbox.uid) } })),
|
|
53
|
+
const title = multiple ? mailbox.displayName : "My calendars";
|
|
54
|
+
return (_jsxs("div", { children: [_jsxs("div", { className: "flex items-center justify-between mb-1 gap-2", children: [_jsxs("h2", { className: "flex items-center gap-1.5 text-xs font-bold uppercase tracking-wide text-text-muted min-w-0", children: [multiple && (_jsx("span", { "aria-hidden": "true", className: "w-2 h-2 rounded-full shrink-0", style: { backgroundColor: accentColorForMailbox(mailbox.uid) } })), _jsxs("span", { className: "truncate", children: [title, multiple && !mailbox.ownerUserUid && _jsx(SharedMailboxMark, {})] })] }), _jsx("button", { type: "button", onClick: () => startAdding(mailbox.uid), "aria-label": multiple ? `Add calendar to ${mailbox.displayName}` : "Add calendar", className: "text-xs font-bold text-primary-dark hover:underline shrink-0", children: "+" })] }), mailboxError && (_jsx("div", { className: "mb-1", children: _jsx(Alert, { children: mailboxError }) })), _jsx("div", { className: "flex flex-col gap-0.5", children: calendarFolders.map((folder) => (_jsxs("label", { className: "flex items-center gap-2 text-sm px-1 py-1 rounded-sm hover:bg-surface-alt cursor-pointer", children: [_jsx("input", { type: "checkbox", checked: checkedFolderUids.has(folder.uid), onChange: () => onToggle(folder.uid), style: { accentColor: colorFor(folder) } }), _jsx("span", { "aria-hidden": "true", className: "w-2.5 h-2.5 rounded-full shrink-0", style: { backgroundColor: colorFor(folder) } }), _jsx("span", { className: "truncate", children: folder.name })] }, folder.uid))) }), addingFor === mailbox.uid && addForm] }, mailbox.uid));
|
|
54
55
|
}) }));
|
|
55
56
|
}
|
|
@@ -1,4 +1,9 @@
|
|
|
1
|
-
import React from "react";
|
|
1
|
+
import React, { ReactNode } from "react";
|
|
2
|
+
/**
|
|
3
|
+
* `text` with every web address in it made a link that opens in a new tab: a description's own links are its author's, but Google Meet joins, dial-in pages
|
|
4
|
+
* and the like are often only typed into the text. Punctuation that ends a sentence, or closes a bracket the address was not in, stays out of the link.
|
|
5
|
+
*/
|
|
6
|
+
export declare function linkifyText(text: string): ReactNode[];
|
|
2
7
|
export interface EventDescriptionViewProps {
|
|
3
8
|
/** The description as HTML, from the server or an invitation - untrusted, so it is read into allowed elements only (`parseEventDescription()`) and drawn as
|
|
4
9
|
* React elements, never as markup. */
|
|
@@ -1,15 +1,45 @@
|
|
|
1
1
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
3
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
4
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
5
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
6
|
+
import React from "react";
|
|
2
7
|
import { htmlToPlainText, parseEventDescription } from "../../../../lib/calendar/eventDescription.js";
|
|
3
|
-
|
|
8
|
+
const URL_PATTERN = /https?:\/\/[^\s<>"']+/gi;
|
|
9
|
+
const LINK_CLASS = "text-primary-dark underline break-words";
|
|
10
|
+
function count(text, character) {
|
|
11
|
+
return text.split(character).length - 1;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* `text` with every web address in it made a link that opens in a new tab: a description's own links are its author's, but Google Meet joins, dial-in pages
|
|
15
|
+
* and the like are often only typed into the text. Punctuation that ends a sentence, or closes a bracket the address was not in, stays out of the link.
|
|
16
|
+
*/
|
|
17
|
+
export function linkifyText(text) {
|
|
18
|
+
const parts = [];
|
|
19
|
+
let last = 0;
|
|
20
|
+
for (const match of text.matchAll(URL_PATTERN)) {
|
|
21
|
+
let url = match[0];
|
|
22
|
+
while (/[.,;:!?]$/.test(url) || (url.endsWith(")") && count(url, ")") > count(url, "(")) || (url.endsWith("]") && count(url, "]") > count(url, "["))) {
|
|
23
|
+
url = url.slice(0, -1);
|
|
24
|
+
}
|
|
25
|
+
const at = match.index;
|
|
26
|
+
parts.push(text.slice(last, at));
|
|
27
|
+
parts.push(_jsx("a", { href: url, target: "_blank", rel: "noopener noreferrer", className: LINK_CLASS, children: url }, at));
|
|
28
|
+
last = at + url.length;
|
|
29
|
+
}
|
|
30
|
+
parts.push(text.slice(last));
|
|
31
|
+
return parts;
|
|
32
|
+
}
|
|
33
|
+
function renderNodes(nodes, insideLink = false) {
|
|
4
34
|
return nodes.map((node, index) => {
|
|
5
35
|
if ("text" in node) {
|
|
6
|
-
return node.text;
|
|
36
|
+
return insideLink ? node.text : _jsx(React.Fragment, { children: linkifyText(node.text) }, index);
|
|
7
37
|
}
|
|
8
38
|
switch (node.tag) {
|
|
9
39
|
case "br":
|
|
10
40
|
return _jsx("br", {}, index);
|
|
11
41
|
case "a":
|
|
12
|
-
return (_jsx("a", { href: node.href, target: "_blank", rel: "noopener noreferrer", className:
|
|
42
|
+
return (_jsx("a", { href: node.href, target: "_blank", rel: "noopener noreferrer", className: LINK_CLASS, children: renderNodes(node.children, true) }, index));
|
|
13
43
|
case "ul":
|
|
14
44
|
return (_jsx("ul", { className: "list-disc pl-5 my-0.5", children: renderNodes(node.children) }, index));
|
|
15
45
|
case "ol":
|
|
@@ -35,5 +65,5 @@ export default function EventDescriptionView({ html, text, className }) {
|
|
|
35
65
|
if (htmlToPlainText(html) !== "") {
|
|
36
66
|
return _jsx("div", { className: ["break-words", className].filter(Boolean).join(" "), children: renderNodes(parseEventDescription(html)) });
|
|
37
67
|
}
|
|
38
|
-
return text?.trim() ? _jsx("div", { className: ["break-words whitespace-pre-wrap", className].filter(Boolean).join(" "), children: text }) : null;
|
|
68
|
+
return text?.trim() ? _jsx("div", { className: ["break-words whitespace-pre-wrap", className].filter(Boolean).join(" "), children: linkifyText(text) }) : null;
|
|
39
69
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
2
|
///////////////////////////////////////////////////////////////////////////////
|
|
3
3
|
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
4
4
|
// SPDX-License-Identifier: MPL-2.0
|
|
@@ -12,13 +12,13 @@ import { htmlToPlainText } from "../../../../lib/calendar/eventDescription.js";
|
|
|
12
12
|
import { deleteEventOccurrence, deleteEventSeries } from "../../../../lib/calendar/calendarMutations.js";
|
|
13
13
|
import { describeRecurrence } from "../../../../lib/calendar/recurrence.js";
|
|
14
14
|
import { getVideoMeeting } from "../../../../lib/videoconf/videoMeetingsApi.js";
|
|
15
|
-
import { deviceTimeZone } from "../../../../lib/util/timeZone.js";
|
|
15
|
+
import { describeTimeZone, deviceTimeZone } from "../../../../lib/util/timeZone.js";
|
|
16
16
|
import Alert from "../../../../lib/components/feedback/Alert.js";
|
|
17
17
|
import Button from "../../../../lib/components/buttons/Button.js";
|
|
18
18
|
import { joinMeetingUrl } from "../../calendar/calendarReminders.js";
|
|
19
19
|
import EventDescriptionView from "./EventDescriptionView.js";
|
|
20
20
|
import RequestChangeForm from "./RequestChangeForm.js";
|
|
21
|
-
import { BUSY_STATUS_LABEL, RESPONSE_STATUS_LABEL, VISIBILITY_LABEL, describeGuestPermissions, describeReminder, formatStoredWhen } from "./eventFormat.js";
|
|
21
|
+
import { BUSY_STATUS_LABEL, RESPONSE_STATUS_LABEL, VISIBILITY_LABEL, describeGuestPermissions, describeReminder, formatStoredWhen, formatStoredWhenInZone } from "./eventFormat.js";
|
|
22
22
|
const ROLE_LABEL = { required: "", optional: "Optional", resource: "Room/equipment" };
|
|
23
23
|
function DetailRow({ icon, children }) {
|
|
24
24
|
return (_jsxs("div", { className: "flex items-start gap-3", children: [_jsx("span", { "aria-hidden": "true", className: "shrink-0 text-text-muted mt-0.5", children: icon }), _jsx("div", { className: "flex-1 min-w-0 break-words", children: children })] }));
|
|
@@ -121,7 +121,7 @@ export default function EventDetails({ occurrence, isInvited, myResponse, isOwnA
|
|
|
121
121
|
{ response: "tentative", label: "Tentative" },
|
|
122
122
|
{ response: "declined", label: "Decline" },
|
|
123
123
|
];
|
|
124
|
-
return (_jsxs("div", { className: "flex flex-col", children: [_jsxs("div", { className: "flex items-start gap-3 px-5 pt-4", children: [_jsx("span", { "aria-hidden": "true", className: "w-3.5 h-3.5 rounded-full shrink-0 mt-2", style: { backgroundColor: calendarColor } }), _jsx("h2", { className: "flex-1 min-w-0 text-xl font-semibold break-words", children: occurrence.title || "(no title)" }), _jsx("button", { type: "button", "aria-label": "Close details", onClick: onClose, className: "w-8 h-8 shrink-0 flex items-center justify-center rounded-full text-text-muted hover:bg-surface-alt hover:text-text", children: _jsx(HiOutlineXMark, { size: 20, "aria-hidden": "true" }) })] }), _jsxs("div", { className: "flex flex-col gap-3 px-5 pt-3 pb-4 text-sm", children: [error && _jsx(Alert, { children: error }), _jsxs(DetailRow, { icon: _jsx(HiOutlineClock, { size: 18 }), children: [
|
|
124
|
+
return (_jsxs("div", { className: "flex flex-col", children: [_jsxs("div", { className: "flex items-start gap-3 px-5 pt-4", children: [_jsx("span", { "aria-hidden": "true", className: "w-3.5 h-3.5 rounded-full shrink-0 mt-2", style: { backgroundColor: calendarColor } }), _jsx("h2", { className: "flex-1 min-w-0 text-xl font-semibold break-words", children: occurrence.title || "(no title)" }), _jsx("button", { type: "button", "aria-label": "Close details", onClick: onClose, className: "w-8 h-8 shrink-0 flex items-center justify-center rounded-full text-text-muted hover:bg-surface-alt hover:text-text", children: _jsx(HiOutlineXMark, { size: 20, "aria-hidden": "true" }) })] }), _jsxs("div", { className: "flex flex-col gap-3 px-5 pt-3 pb-4 text-sm", children: [error && _jsx(Alert, { children: error }), _jsxs(DetailRow, { icon: _jsx(HiOutlineClock, { size: 18 }), children: [!occurrence.allDay && eventZone && eventZone !== deviceTimeZone() ? (_jsxs(_Fragment, { children: [_jsxs("div", { children: [formatStoredWhen(occurrence.startDate, occurrence.endDate, false), " \u2022 ", describeTimeZone(deviceTimeZone(), new Date(occurrence.startDate))] }), _jsxs("div", { className: "text-text-muted", children: [formatStoredWhenInZone(occurrence.startDate, occurrence.endDate, eventZone), " \u2022 ", describeTimeZone(eventZone, new Date(occurrence.startDate))] })] })) : (_jsx("div", { children: formatStoredWhen(occurrence.startDate, occurrence.endDate, occurrence.allDay) })), occurrence.recurrenceRule && _jsxs("div", { className: "text-text-muted", children: ["Repeats ", describeRecurrence(occurrence.recurrenceRule)] })] }), occurrence.location && (_jsx(DetailRow, { icon: _jsx(HiOutlineMapPin, { size: 18 }), children: _jsx("div", { children: occurrence.location }) })), hasDescription && (_jsx(DetailRow, { icon: _jsx(HiOutlineBars3BottomLeft, { size: 18 }), children: _jsx(EventDescriptionView, { html: occurrence.descriptionHtml, text: occurrence.description }) })), videoMeetingUid && (_jsx(DetailRow, { icon: _jsx(HiOutlineVideoCamera, { size: 18 }), children: _jsxs("div", { className: "flex items-center gap-3 flex-wrap", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", disabled: !joinUrl, onClick: () => window.open(joinUrl, "_blank", "noopener,noreferrer"), children: "Join video call" }), !joinUrl && (_jsx("span", { className: "text-xs text-text-muted", children: organizerJoinUrl === undefined ? "Loading the join link…" : "This meeting’s join link isn’t available." }))] }) })), _jsxs(DetailRow, { icon: _jsx(HiOutlineUser, { size: 18 }), children: [_jsx("div", { children: organizerName || occurrence.organizer.address }), _jsxs("div", { className: "text-text-muted", children: [organizerName ? `${occurrence.organizer.address} · ` : "", "Organizer"] })] }), (guests.length > 0 || hiddenList) && (_jsxs(DetailRow, { icon: _jsx(HiOutlineUserGroup, { size: 18 }), children: [_jsx("div", { className: "font-medium mb-1", children: hiddenList ? "The organizer has hidden the guest list" : guests.length === 1 ? "1 guest" : `${guests.length} guests` }), _jsx("ul", { className: "flex flex-col gap-1", children: guests.map((guest, i) => (_jsxs("li", { className: "flex items-baseline justify-between gap-3", children: [_jsxs("span", { className: "min-w-0 truncate", children: [guest.displayName || guest.address, ROLE_LABEL[guest.role] && _jsxs("span", { className: "text-text-muted", children: [" (", ROLE_LABEL[guest.role], ")"] })] }), _jsx("span", { className: "shrink-0 text-xs text-text-muted", children: RESPONSE_STATUS_LABEL[guest.responseStatus] })] }, i))) }), !isInvited && _jsx("div", { className: "text-xs text-text-muted mt-1", children: describeGuestPermissions(permissions) })] })), _jsxs(DetailRow, { icon: _jsx(HiOutlineBriefcase, { size: 18 }), children: [_jsx("div", { children: BUSY_STATUS_LABEL[occurrence.busyStatus] }), _jsx("div", { className: "text-text-muted", children: VISIBILITY_LABEL[visibilityOf(occurrence)] }), occurrence.autoReplyEnabled && (_jsx("div", { className: "text-text-muted", children: "Sends an automatic reply while this event is happening" }))] }), _jsx(DetailRow, { icon: _jsx(HiOutlineBell, { size: 18 }), children: _jsx("div", { children: describeReminder(occurrence.reminderMinutesBeforeStart) }) }), calendarName && (_jsxs("div", { className: "flex items-center gap-3 text-text-muted", children: [_jsx("span", { "aria-hidden": "true", className: "w-3 h-3 ml-[3px] rounded-full shrink-0", style: { backgroundColor: calendarColor } }), _jsx("span", { className: "truncate", children: calendarName })] })), isInvited && (_jsxs("p", { className: "text-xs text-text-muted", children: ["You were invited to this event. Only the organizer can change its details", canChange || canInvite ? ", but you can ask them to." : "."] }))] }), requesting && (_jsx("div", { className: "mx-5 mb-3 p-3 rounded-md bg-surface-alt", children: _jsx(RequestChangeForm, { subject: {
|
|
125
125
|
uid: occurrence.uid,
|
|
126
126
|
title: occurrence.title,
|
|
127
127
|
location: occurrence.location,
|
|
@@ -19,6 +19,7 @@ import EventExpandedForm from "./EventExpandedForm.js";
|
|
|
19
19
|
import QuickCreateFaces from "./QuickCreateFaces.js";
|
|
20
20
|
import { descriptionProblem, dialogFields, hasDescriptionText, initialDescriptionHtml } from "./eventDialogFields.js";
|
|
21
21
|
import { mergeGuests, msToWallString, wallStringToMs } from "./eventFormat.js";
|
|
22
|
+
import { nextHalfHour } from "./timePicker.js";
|
|
22
23
|
/**
|
|
23
24
|
* The Location text this modal writes when it mints a video meeting for an event, and the one value it will
|
|
24
25
|
* clear again when video conferencing is turned back off (see `applyVideoConferencing()`).
|
|
@@ -120,8 +121,10 @@ export default function EventEditor({ layout, onExpand, onCancel, onSaved, onSyn
|
|
|
120
121
|
end = start;
|
|
121
122
|
}
|
|
122
123
|
else {
|
|
123
|
-
|
|
124
|
-
|
|
124
|
+
// With nothing clicked, a new event starts at the next half hour and lasts an hour.
|
|
125
|
+
const newStart = initialStart ?? nextHalfHour(new Date());
|
|
126
|
+
start = toDatetimeLocal(occurrence?.startDate ?? newStart.toISOString());
|
|
127
|
+
end = toDatetimeLocal(occurrence?.endDate ?? (initialEnd ?? new Date(newStart.getTime() + MS_PER_HOUR)).toISOString());
|
|
125
128
|
}
|
|
126
129
|
return {
|
|
127
130
|
title: occurrence?.title ?? "",
|
|
@@ -44,7 +44,7 @@ export declare function RecurrenceSelect({ value, onChange, allDay, startWeekday
|
|
|
44
44
|
allDay: boolean;
|
|
45
45
|
startWeekday: WeekdayCode | undefined;
|
|
46
46
|
}): React.JSX.Element;
|
|
47
|
-
/** [start date] [start time] to [end time] [end date], then
|
|
47
|
+
/** [start date] [start time] to [end time] [end date], then the "Time zone" link (and its menu), "All day" and how the event repeats. */
|
|
48
48
|
export declare function DateTimeControls({ c }: {
|
|
49
49
|
c: EventFormController;
|
|
50
50
|
}): React.JSX.Element;
|
|
@@ -9,9 +9,12 @@ import Button from "../../../../lib/components/buttons/Button.js";
|
|
|
9
9
|
import GuestInput from "./GuestInput.js";
|
|
10
10
|
import LazyDescriptionEditor from "./LazyDescriptionEditor.js";
|
|
11
11
|
import RecurrenceEditor from "./RecurrenceEditor.js";
|
|
12
|
+
import TimeComboBox from "./TimeComboBox.js";
|
|
13
|
+
import TimeZonePicker from "../../../../lib/components/pickers/TimeZonePicker.js";
|
|
12
14
|
import ResourcePicker from "./ResourcePicker.js";
|
|
13
15
|
import { hasDescriptionText } from "./eventDialogFields.js";
|
|
14
|
-
import {
|
|
16
|
+
import { dayTimes, endTimeOptions, formatTimeOfDay } from "./timePicker.js";
|
|
17
|
+
import { REMINDER_UNITS, RESPONSE_STATUS_LABEL, VISIBILITIES, VISIBILITY_HELP, VISIBILITY_LABEL, bestReminderUnit, zoneDate, } from "./eventFormat.js";
|
|
15
18
|
export const INPUT_CLASS = "w-full text-sm py-2 px-3 border border-border rounded-md bg-surface text-text focus:outline-none focus:border-primary";
|
|
16
19
|
export const SELECT_CLASS = "text-sm py-2 px-2 border border-border rounded-md bg-surface text-text focus:outline-none focus:border-primary";
|
|
17
20
|
/** A text field that reads as plain text until it is hovered or focused, as a row of the quick-create popover does. */
|
|
@@ -139,11 +142,14 @@ export function RecurrenceSelect({ value, onChange, allDay, startWeekday, }) {
|
|
|
139
142
|
}
|
|
140
143
|
return (_jsxs("div", { className: "flex flex-col gap-2", children: [_jsxs("select", { id: "event-recurrence", "aria-label": "Recurrence", className: SELECT_CLASS, value: selected, onChange: (e) => choose(e.target.value), children: [_jsx("option", { value: "none", children: "Does not repeat" }), _jsx("option", { value: "daily", children: "Daily" }), _jsx("option", { value: "weekly", children: startWeekday ? `Weekly on ${WEEKDAY_NAME[startWeekday]}` : "Weekly" }), _jsx("option", { value: "monthly", children: "Monthly" }), _jsx("option", { value: "yearly", children: "Annually" }), _jsx("option", { value: "weekdays", children: "Every weekday (Monday to Friday)" }), _jsx("option", { value: "custom", children: "Custom\u2026" })] }), selected === "custom" && _jsx(RecurrenceEditor, { value: value, onChange: onChange, allDay: allDay, startWeekday: startWeekday, hideToggle: true })] }));
|
|
141
144
|
}
|
|
142
|
-
|
|
145
|
+
const START_TIME_OPTIONS = dayTimes().map((time) => ({ value: time, label: formatTimeOfDay(time) }));
|
|
146
|
+
/** [start date] [start time] to [end time] [end date], then the "Time zone" link (and its menu), "All day" and how the event repeats. */
|
|
143
147
|
export function DateTimeControls({ c }) {
|
|
144
148
|
const { values } = c;
|
|
145
149
|
const [zoneOpen, setZoneOpen] = useState(false);
|
|
146
|
-
return (_jsxs("div", { className: "flex flex-col gap-3", children: [_jsxs("div", { className: "flex flex-wrap items-center gap-2 text-sm", children: [_jsx("input", { type: "date", "aria-label": "Event start date", className: `${SELECT_CLASS} min-w-0`, value: values.start.slice(0, 10), onChange: (e) => c.setStart(`${e.target.value}${values.start.slice(10)}`) }), !values.allDay && (_jsx(
|
|
150
|
+
return (_jsxs("div", { className: "flex flex-col gap-3", children: [_jsxs("div", { className: "flex flex-wrap items-center gap-2 text-sm", children: [_jsx("input", { type: "date", "aria-label": "Event start date", className: `${SELECT_CLASS} min-w-0`, value: values.start.slice(0, 10), onChange: (e) => c.setStart(`${e.target.value}${values.start.slice(10)}`) }), !values.allDay && (_jsx(TimeComboBox, { "aria-label": "Event start time", className: `${SELECT_CLASS} w-28`, display: formatTimeOfDay(values.start.slice(11, 16)), current: values.start.slice(11, 16), options: START_TIME_OPTIONS,
|
|
151
|
+
// Moving the start moves the end with it, so the event keeps its length: whatever the start is set to.
|
|
152
|
+
onSelect: (time) => c.setStart(`${values.start.slice(0, 11)}${time}`), onType: (time) => c.setStart(`${values.start.slice(0, 11)}${time}`) })), _jsx("span", { className: "text-text-muted", children: "to" }), !values.allDay && (_jsx(TimeComboBox, { "aria-label": "Event end time", className: `${SELECT_CLASS} w-28`, display: formatTimeOfDay(values.end.slice(11, 16)), current: values.end, options: endTimeOptions(values.start).map((option) => ({ value: option.value, label: option.label, hint: option.hint })), onSelect: c.setEnd, onType: (time) => c.setEnd(`${values.end.slice(0, 11)}${time}`) })), _jsx("input", { type: "date", "aria-label": "Event end date", className: `${SELECT_CLASS} min-w-0`, value: values.end.slice(0, 10), onChange: (e) => c.setEnd(`${e.target.value}${values.end.slice(10)}`) })] }), _jsx("button", { type: "button", "aria-expanded": zoneOpen, className: "self-start text-sm text-primary-dark hover:underline", onClick: () => setZoneOpen((open) => !open), children: "Time zone" }), zoneOpen && (_jsx(TimeZonePicker, { "aria-label": "Event time zone", className: `${SELECT_CLASS} max-w-full`, value: values.formZone, zones: c.timeZones, at: zoneDate(values.start), onChange: c.onTimeZoneChange })), _jsxs("label", { className: "flex items-center gap-2 text-sm", children: [_jsx("input", { type: "checkbox", checked: values.allDay, onChange: (e) => c.onAllDayChange(e.target.checked) }), "All day"] }), !c.editingSingleOccurrence && (_jsx(RecurrenceSelect, { value: values.recurrenceRule, onChange: (rule) => c.update({ recurrenceRule: rule }), allDay: values.allDay, startWeekday: c.startWeekday }))] }));
|
|
147
153
|
}
|
|
148
154
|
/** The calendar this event is in: its colour and name, a choice between calendars (and mailboxes) when an event is being created. */
|
|
149
155
|
export function CalendarField({ c }) {
|
|
@@ -10,7 +10,8 @@ import Alert from "../../../../lib/components/feedback/Alert.js";
|
|
|
10
10
|
import Button from "../../../../lib/components/buttons/Button.js";
|
|
11
11
|
import { EventShellContext } from "./EventShell.js";
|
|
12
12
|
import { CalendarField, DateTimeControls, DescriptionRow, GuestsRow, IconRow, LocationRow, VideoConferencingRow } from "./EventFormParts.js";
|
|
13
|
-
import {
|
|
13
|
+
import { describeTimeZone } from "../../../../lib/util/timeZone.js";
|
|
14
|
+
import { BUSY_STATUS_LABEL, VISIBILITY_LABEL, describeReminder, formatFormWhen, reminderOf, zoneDate } from "./eventFormat.js";
|
|
14
15
|
/**
|
|
15
16
|
* The quick-create popover: a title, when (a click opens the date, time, zone and repeat controls in place), guests, video conferencing,
|
|
16
17
|
* a location, an "Add description" row (which opens the rich-text box in place) and which calendar - with the busy status, visibility and reminder it will have
|
|
@@ -21,5 +22,5 @@ export default function EventQuickForm({ c, onExpand, tabs, tabPanelProps }) {
|
|
|
21
22
|
const { values } = c;
|
|
22
23
|
const { dragHandleProps } = useContext(EventShellContext);
|
|
23
24
|
const [timeOpen, setTimeOpen] = useState(false);
|
|
24
|
-
return (_jsxs("form", { onSubmit: c.onSubmit, className: "flex flex-col", children: [_jsxs("div", { className: "flex items-center justify-between px-2 pt-2", children: [dragHandleProps ? (_jsx("div", { ...dragHandleProps, "aria-hidden": "true", className: "flex-1 h-7 flex items-center cursor-move text-text-muted select-none touch-none", children: _jsx(HiOutlineBars2, { size: 20 }) })) : (_jsx("div", { className: "flex-1" })), _jsx("button", { type: "button", "aria-label": "Close", onClick: c.onCancel, className: "w-8 h-8 flex items-center justify-center rounded-full text-text-muted hover:bg-surface-alt hover:text-text", children: _jsx(HiOutlineXMark, { size: 20, "aria-hidden": "true" }) })] }), tabs, _jsxs("div", { ...tabPanelProps, className: "flex flex-col gap-3 px-5 pb-3", children: [c.error && _jsx(Alert, { children: c.error }), _jsx("div", { className: "pl-8", children: _jsx("input", { type: "text", "aria-label": "Title", placeholder: "Add title", "data-autofocus": true, className: "w-full text-xl bg-transparent text-text border-0 border-b-2 border-primary/50 focus:border-primary focus:outline-none pb-1 placeholder:text-text-muted", value: values.title, onChange: (e) => c.update({ title: e.target.value }) }) }), _jsxs(IconRow, { icon: _jsx(HiOutlineClock, { size: 20 }), children: [_jsxs("button", { type: "button", "aria-expanded": timeOpen, onClick: () => setTimeOpen((open) => !open), className: "w-full text-left rounded-md hover:bg-surface-alt -mx-1 px-1 py-0.5", children: [_jsx("span", { className: "block text-sm", children: formatFormWhen(values.start, values.end, values.allDay) }), _jsxs("span", { className: "block text-xs text-text-muted", children: [values.formZone
|
|
25
|
+
return (_jsxs("form", { onSubmit: c.onSubmit, className: "flex flex-col", children: [_jsxs("div", { className: "flex items-center justify-between px-2 pt-2", children: [dragHandleProps ? (_jsx("div", { ...dragHandleProps, "aria-hidden": "true", className: "flex-1 h-7 flex items-center cursor-move text-text-muted select-none touch-none", children: _jsx(HiOutlineBars2, { size: 20 }) })) : (_jsx("div", { className: "flex-1" })), _jsx("button", { type: "button", "aria-label": "Close", onClick: c.onCancel, className: "w-8 h-8 flex items-center justify-center rounded-full text-text-muted hover:bg-surface-alt hover:text-text", children: _jsx(HiOutlineXMark, { size: 20, "aria-hidden": "true" }) })] }), tabs, _jsxs("div", { ...tabPanelProps, className: "flex flex-col gap-3 px-5 pb-3", children: [c.error && _jsx(Alert, { children: c.error }), _jsx("div", { className: "pl-8", children: _jsx("input", { type: "text", "aria-label": "Title", placeholder: "Add title", "data-autofocus": true, className: "w-full text-xl bg-transparent text-text border-0 border-b-2 border-primary/50 focus:border-primary focus:outline-none pb-1 placeholder:text-text-muted", value: values.title, onChange: (e) => c.update({ title: e.target.value }) }) }), _jsxs(IconRow, { icon: _jsx(HiOutlineClock, { size: 20 }), children: [_jsxs("button", { type: "button", "aria-expanded": timeOpen, onClick: () => setTimeOpen((open) => !open), className: "w-full text-left rounded-md hover:bg-surface-alt -mx-1 px-1 py-0.5", children: [_jsx("span", { className: "block text-sm", children: formatFormWhen(values.start, values.end, values.allDay) }), _jsxs("span", { className: "block text-xs text-text-muted", children: [describeTimeZone(values.formZone, zoneDate(values.start)), " \u2022", " ", values.recurrenceRule ? `Repeats ${describeRecurrence(values.recurrenceRule)}` : "Does not repeat"] })] }), timeOpen && (_jsx("div", { className: "mt-2", children: _jsx(DateTimeControls, { c: c }) }))] }), _jsx(GuestsRow, { c: c }), _jsx(VideoConferencingRow, { c: c }), _jsx(LocationRow, { c: c }), _jsx(DescriptionRow, { c: c, collapsible: true }), _jsxs(IconRow, { icon: _jsx(HiOutlineCalendarDays, { size: 20 }), children: [_jsx(CalendarField, { c: c }), _jsxs("p", { className: "text-xs text-text-muted mt-0.5", children: [BUSY_STATUS_LABEL[values.busyStatus], " \u2022 ", VISIBILITY_LABEL[values.visibility], " \u2022 ", describeReminder(reminderOf(values.reminderMinutes))] })] })] }), _jsxs("div", { className: "flex items-center justify-end gap-2 px-4 py-3", children: [_jsx(Button, { type: "button", variant: "text", className: "!w-auto", onClick: onExpand, children: "More options" }), _jsx(Button, { type: "submit", loading: c.saving, disabled: c.saving, className: "!w-auto", children: "Save" })] })] }));
|
|
25
26
|
}
|
|
@@ -9,7 +9,8 @@ import { FREE_BUSY_MAX_ADDRESSES, availabilityDuring, availabilityOf, getFreeBus
|
|
|
9
9
|
import Button from "../../../../lib/components/buttons/Button.js";
|
|
10
10
|
import { useApiClient } from "../../../../lib/util/apiClientContext.js";
|
|
11
11
|
import { addDaysToKey } from "./allDay.js";
|
|
12
|
-
import { formatFormWhen, localDateFromKey, msToWallString, wallStringToMs } from "./eventFormat.js";
|
|
12
|
+
import { formatFormWhen, localDateFromKey, msToWallString, wallStringToMs, zoneDate } from "./eventFormat.js";
|
|
13
|
+
import { describeTimeZone } from "../../../../lib/util/timeZone.js";
|
|
13
14
|
import { SEARCH_DAYS, WORK_END_HOUR, WORK_START_HOUR, clockLabel, dayStartMs, freeBusyErrorMessage, isDayKey, minuteOfDay, summaryLine, workingWindows, } from "./findTimeGrid.js";
|
|
14
15
|
/** How long the grid waits after the last change of day or guests before asking the server, so typing a list of guests is one look-up. */
|
|
15
16
|
export const FIND_TIME_DEBOUNCE_MS = 300;
|
|
@@ -167,7 +168,7 @@ export default function FindATime({ c }) {
|
|
|
167
168
|
.map((block) => `${block.tentative ? "Tentatively busy" : "Busy"} ${clockLabel(block.startMs, formZone, deviceZone)} to ${clockLabel(block.endMs, formZone, deviceZone)}`)
|
|
168
169
|
.join(", ");
|
|
169
170
|
}
|
|
170
|
-
return (_jsxs("div", { className: "flex flex-col gap-3 min-w-0", children: [_jsxs("div", { className: "flex flex-wrap items-center gap-2", children: [_jsx("button", { type: "button", "aria-label": "Previous day", onClick: () => setDayKey((key) => addDaysToKey(key, -1)), className: "w-8 h-8 flex items-center justify-center rounded-full text-text-muted hover:bg-surface-alt hover:text-text", children: "\u2039" }), _jsx("span", { className: "text-sm font-medium min-w-[9rem] text-center", children: dayLabel }), _jsx("button", { type: "button", "aria-label": "Next day", onClick: () => setDayKey((key) => addDaysToKey(key, 1)), className: "w-8 h-8 flex items-center justify-center rounded-full text-text-muted hover:bg-surface-alt hover:text-text", children: "\u203A" }), _jsx("span", { className: "text-xs text-text-muted", children: formZone
|
|
171
|
+
return (_jsxs("div", { className: "flex flex-col gap-3 min-w-0", children: [_jsxs("div", { className: "flex flex-wrap items-center gap-2", children: [_jsx("button", { type: "button", "aria-label": "Previous day", onClick: () => setDayKey((key) => addDaysToKey(key, -1)), className: "w-8 h-8 flex items-center justify-center rounded-full text-text-muted hover:bg-surface-alt hover:text-text", children: "\u2039" }), _jsx("span", { className: "text-sm font-medium min-w-[9rem] text-center", children: dayLabel }), _jsx("button", { type: "button", "aria-label": "Next day", onClick: () => setDayKey((key) => addDaysToKey(key, 1)), className: "w-8 h-8 flex items-center justify-center rounded-full text-text-muted hover:bg-surface-alt hover:text-text", children: "\u203A" }), _jsx("span", { className: "text-xs text-text-muted", children: describeTimeZone(formZone, zoneDate(dayKey)) })] }), lookUp.status === "error" && (_jsxs("div", { role: "alert", className: "flex flex-wrap items-center gap-3 text-sm text-danger", children: [_jsx("span", { children: lookUp.message }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", onClick: () => setRetry((count) => count + 1), children: "Try again" })] })), _jsx("div", { ref: scroller, role: "group", "aria-label": "Guests' availability", "aria-busy": lookUp.status === "loading", className: "overflow-x-auto border border-border rounded-md", children: _jsxs("div", { className: "relative", style: { width: NAME_WIDTH + TIMELINE_WIDTH }, children: [_jsxs("div", { className: "flex bg-surface border-b border-border", style: { height: HEADER_HEIGHT }, children: [_jsx("div", { className: "sticky left-0 z-20 shrink-0 bg-surface border-r border-border", style: { width: NAME_WIDTH } }), HOURS.map((hour) => (_jsx("button", { type: "button", "aria-label": `Start at ${hourLabel(hour)}`, title: `Start at ${hourLabel(hour)}`, onClick: () => startAt(hour * 60), className: "shrink-0 text-[10px] text-text-muted hover:bg-surface-alt hover:text-text text-left pl-1", style: { width: HOUR_WIDTH }, children: hourLabel(hour) }, hour)))] }), rows.map((row) => {
|
|
171
172
|
const person = byAddress.get(row.address.toLowerCase());
|
|
172
173
|
const state = person && timeIsValid && covered ? availabilityDuring(person, eventStart, eventEnd) : undefined;
|
|
173
174
|
const hidden = person && person.status !== "available" ? person : undefined;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
export interface TimeOption {
|
|
3
|
+
value: string;
|
|
4
|
+
label: string;
|
|
5
|
+
/** Said after the label, muted: the length of the event an end time makes. */
|
|
6
|
+
hint?: string;
|
|
7
|
+
}
|
|
8
|
+
export interface TimeComboBoxProps {
|
|
9
|
+
"aria-label": string;
|
|
10
|
+
/** The time being shown, as it reads in the field. */
|
|
11
|
+
display: string;
|
|
12
|
+
/** The `value` of the option that is the current one, if any of them is. */
|
|
13
|
+
current?: string;
|
|
14
|
+
options: TimeOption[];
|
|
15
|
+
/** An option was picked. */
|
|
16
|
+
onSelect: (value: string) => void;
|
|
17
|
+
/** A time was typed (`HH:mm`, already understood). */
|
|
18
|
+
onType: (time: string) => void;
|
|
19
|
+
className?: string;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* A time field that is a text box and a menu at once: focusing it opens a list of times to pick from, and whatever is typed instead - "3:45pm", "1530" -
|
|
23
|
+
* is understood when the field is left or Enter is pressed (and put back as it was when it isn't a time, or on Escape). The list follows the arrow keys.
|
|
24
|
+
*/
|
|
25
|
+
export default function TimeComboBox({ "aria-label": label, display, current, options, onSelect, onType, className }: TimeComboBoxProps): React.JSX.Element;
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
3
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
4
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
5
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
6
|
+
import { useEffect, useId, useRef, useState } from "react";
|
|
7
|
+
import { parseTimeInput } from "./timePicker.js";
|
|
8
|
+
/**
|
|
9
|
+
* A time field that is a text box and a menu at once: focusing it opens a list of times to pick from, and whatever is typed instead - "3:45pm", "1530" -
|
|
10
|
+
* is understood when the field is left or Enter is pressed (and put back as it was when it isn't a time, or on Escape). The list follows the arrow keys.
|
|
11
|
+
*/
|
|
12
|
+
export default function TimeComboBox({ "aria-label": label, display, current, options, onSelect, onType, className }) {
|
|
13
|
+
const listId = useId();
|
|
14
|
+
const [draft, setDraft] = useState(null);
|
|
15
|
+
const [open, setOpen] = useState(false);
|
|
16
|
+
const [active, setActive] = useState(-1);
|
|
17
|
+
const selectedRef = useRef(null);
|
|
18
|
+
// Opening puts the current time in view, as the middle of the list.
|
|
19
|
+
useEffect(() => {
|
|
20
|
+
if (open) {
|
|
21
|
+
selectedRef.current?.scrollIntoView?.({ block: "center" });
|
|
22
|
+
}
|
|
23
|
+
}, [open]);
|
|
24
|
+
function close() {
|
|
25
|
+
setOpen(false);
|
|
26
|
+
setActive(-1);
|
|
27
|
+
setDraft(null);
|
|
28
|
+
}
|
|
29
|
+
function commitDraft() {
|
|
30
|
+
const time = draft === null ? null : parseTimeInput(draft);
|
|
31
|
+
if (time !== null) {
|
|
32
|
+
onType(time);
|
|
33
|
+
}
|
|
34
|
+
close();
|
|
35
|
+
}
|
|
36
|
+
function pick(value) {
|
|
37
|
+
onSelect(value);
|
|
38
|
+
close();
|
|
39
|
+
}
|
|
40
|
+
function handleKeyDown(event) {
|
|
41
|
+
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
|
|
42
|
+
event.preventDefault();
|
|
43
|
+
setOpen(true);
|
|
44
|
+
setActive((index) => {
|
|
45
|
+
const from = index < 0 ? options.findIndex((option) => option.value === current) : index;
|
|
46
|
+
return Math.min(Math.max(from + (event.key === "ArrowDown" ? 1 : -1), 0), options.length - 1);
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
else if (event.key === "Enter") {
|
|
50
|
+
// Never the form's own submit: Enter here settles the time.
|
|
51
|
+
event.preventDefault();
|
|
52
|
+
if (open && active >= 0) {
|
|
53
|
+
pick(options[active].value);
|
|
54
|
+
}
|
|
55
|
+
else {
|
|
56
|
+
commitDraft();
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
else if (event.key === "Escape" && open) {
|
|
60
|
+
event.stopPropagation();
|
|
61
|
+
close();
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return (_jsxs("div", { className: "relative", children: [_jsx("input", { type: "text", role: "combobox", "aria-label": label, "aria-expanded": open, "aria-controls": listId, "aria-autocomplete": "list", "aria-activedescendant": active >= 0 ? `${listId}-${active}` : undefined, autoComplete: "off", className: className, value: draft ?? display, onClick: () => setOpen(true), onFocus: (event) => {
|
|
65
|
+
event.currentTarget.select();
|
|
66
|
+
setOpen(true);
|
|
67
|
+
}, onChange: (event) => setDraft(event.target.value), onBlur: commitDraft, onKeyDown: handleKeyDown }), open && (_jsx("ul", { id: listId, role: "listbox", "aria-label": `${label} options`, className: "absolute left-0 top-full z-30 mt-1 max-h-56 w-44 overflow-y-auto rounded-md border border-border bg-surface py-1 shadow-lg", children: options.map((option, index) => (_jsxs("li", { id: `${listId}-${index}`, ref: option.value === current ? selectedRef : undefined, role: "option", "aria-selected": option.value === current,
|
|
68
|
+
// Before the field's own blur, which would otherwise close the list and swallow the click.
|
|
69
|
+
onMouseDown: (event) => {
|
|
70
|
+
event.preventDefault();
|
|
71
|
+
pick(option.value);
|
|
72
|
+
}, className: [
|
|
73
|
+
"flex cursor-pointer items-baseline gap-1.5 px-3 py-1.5 text-sm hover:bg-surface-alt",
|
|
74
|
+
option.value === current ? "font-semibold text-primary-dark" : "",
|
|
75
|
+
index === active ? "bg-surface-alt" : "",
|
|
76
|
+
].join(" "), children: [option.label, option.hint && _jsx("span", { className: "text-xs font-normal text-text-muted", children: option.hint })] }, option.value))) }))] }));
|
|
77
|
+
}
|
|
@@ -67,6 +67,10 @@ export declare function localDateFromKey(key: string): Date;
|
|
|
67
67
|
export declare function formatWhen(start: Date, end: Date, allDay: boolean, now?: Date): string;
|
|
68
68
|
/** `formatWhen()` for a stored event: instants for a timed one, the date-only keys (with the exclusive end) for an all-day one. */
|
|
69
69
|
export declare function formatStoredWhen(startDate: string, endDate: string, allDay: boolean, now?: Date): string;
|
|
70
|
+
/** The date the form's start falls on, for reading a zone's offset as it will be for the event (daylight saving); now while there is no date. */
|
|
71
|
+
export declare function zoneDate(start: string): Date;
|
|
72
|
+
/** `formatWhen()` for a stored timed event as `zone`'s clock reads it, whatever zone the viewer is in. */
|
|
73
|
+
export declare function formatStoredWhenInZone(startDate: string, endDate: string, zone: string, now?: Date): string;
|
|
70
74
|
/** Adds the guests typed or pasted into the guests field to `current` - recipients separated by commas or semicolons, each an address or
|
|
71
75
|
* `Name <address>` (see `applyGuestChips()`, which keeps who is already there): an address already on the list (in any case) or in
|
|
72
76
|
* `skipAddresses` is skipped, and what is not an address comes back as `invalid` for the field to keep. */
|