@rapidmx/web-client 0.23.0 → 0.24.0
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/settings/MailboxCreateForm.tsx +2 -1
- package/apps/shared/components/calendar/AppointmentQuickForm.tsx +2 -1
- 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/mail/invite/inviteFormat.ts +6 -5
- 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/settings/MailboxCreateForm.js +2 -2
- package/dist/apps/shared/components/calendar/AppointmentQuickForm.js +2 -1
- 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/mail/invite/inviteFormat.js +6 -5
- 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 +100 -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 +46 -2
- package/lib/auth/session.ts +48 -3
- package/lib/components/pickers/TimeZonePicker.tsx +180 -0
- package/lib/util/api.ts +24 -1
- package/lib/util/timeZone.ts +50 -2
- package/package.json +1 -1
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
///////////////////////////////////////////////////////////////////////////////
|
|
5
5
|
import type { Attachment } from "../../../../../lib/mail/mailApi.js";
|
|
6
6
|
import type { InviteResponse, MessageInvite } from "../../../../../lib/calendar/inviteApi.js";
|
|
7
|
+
import { describeTimeZone, deviceTimeZone } from "../../../../../lib/util/timeZone.js";
|
|
7
8
|
import { APP_HREFS } from "../../../navigation/appHrefs.js";
|
|
8
9
|
|
|
9
10
|
/** Whether an attachment is a calendar file - what tells the pane a message may carry an invitation worth asking the server about. By type
|
|
@@ -53,15 +54,15 @@ export function formatInviteWhen(invite: Pick<MessageInvite, "startDate" | "endD
|
|
|
53
54
|
}
|
|
54
55
|
const date = new Intl.DateTimeFormat(undefined, DAY_FORMAT);
|
|
55
56
|
const time = new Intl.DateTimeFormat(undefined, CLOCK_FORMAT);
|
|
56
|
-
// The zone is named once, after the last time shown: the times are the reader's own, not the organizer's.
|
|
57
|
-
const
|
|
57
|
+
// The zone is named once, in full, after the last time shown: the times are the reader's own, not the organizer's.
|
|
58
|
+
const zone = describeTimeZone(deviceTimeZone(), end ?? start);
|
|
58
59
|
const startDay = date.format(start);
|
|
59
60
|
if (!end) {
|
|
60
|
-
return `${startDay}, ${
|
|
61
|
+
return `${startDay}, ${time.format(start)} • ${zone}`;
|
|
61
62
|
}
|
|
62
63
|
return startDay === date.format(end)
|
|
63
|
-
? `${startDay}, ${time.format(start)} - ${
|
|
64
|
-
: `${startDay}, ${time.format(start)} - ${date.format(end)}, ${
|
|
64
|
+
? `${startDay}, ${time.format(start)} - ${time.format(end)} • ${zone}`
|
|
65
|
+
: `${startDay}, ${time.format(start)} - ${date.format(end)}, ${time.format(end)} • ${zone}`;
|
|
65
66
|
}
|
|
66
67
|
|
|
67
68
|
/** The meeting's start the way a list row writes it - `Thu 9/24/2026 1:00 PM` (dates only for an all-day event), in the reader's zone. */
|
|
@@ -43,6 +43,7 @@ import TimeGridView from "../../shared/components/calendar/TimeGridView.js";
|
|
|
43
43
|
import Alert from "../../../lib/components/feedback/Alert.js";
|
|
44
44
|
import Button from "../../../lib/components/buttons/Button.js";
|
|
45
45
|
import { SWIPE_PERIOD_SHIFT } from "../../shared/components/calendar/swipeNavigation.js";
|
|
46
|
+
import { nextHalfHour } from "../../shared/components/calendar/timePicker.js";
|
|
46
47
|
import { useSwipeSlide } from "../../shared/gestures/useSwipeSlide.js";
|
|
47
48
|
import { useEnterSlide } from "../../shared/gestures/useEnterSlide.js";
|
|
48
49
|
import { useWheelPaging, type WheelEdge } from "../../shared/gestures/useWheelPaging.js";
|
|
@@ -357,10 +358,12 @@ function CalendarContent({ userUid, bookingHref }: { userUid?: string; bookingHr
|
|
|
357
358
|
if (!mailboxUid || !folderUid) {
|
|
358
359
|
return;
|
|
359
360
|
}
|
|
361
|
+
// With nothing clicked, the next half hour, for an hour.
|
|
362
|
+
const from = start ?? nextHalfHour(new Date());
|
|
360
363
|
setModal({
|
|
361
364
|
occurrence: null,
|
|
362
|
-
initialStart:
|
|
363
|
-
initialEnd: end ?? new Date(
|
|
365
|
+
initialStart: from,
|
|
366
|
+
initialEnd: end ?? new Date(from.getTime() + 60 * 60_000),
|
|
364
367
|
targetFolderUid,
|
|
365
368
|
anchor,
|
|
366
369
|
initialAllDay,
|
|
@@ -5,7 +5,8 @@
|
|
|
5
5
|
import { pageTitle } from "../../../shared/navigation/pageTitle.js";
|
|
6
6
|
import React, { FormEvent, useMemo, useState } from "react";
|
|
7
7
|
import { FreeBusyVisibility, freeBusyVisibilityOf, isSharedWithMe, updateMailbox } from "../../../../lib/mail/mailApi.js";
|
|
8
|
-
import
|
|
8
|
+
import TimeZonePicker from "../../../../lib/components/pickers/TimeZonePicker.js";
|
|
9
|
+
import { DEFAULT_TIME_ZONE, describeTimeZone, deviceTimeZone, timeZoneOptions } from "../../../../lib/util/timeZone.js";
|
|
9
10
|
import SettingsShell, { SettingsShellProps, useSettingsShell } from "../../../shared/components/settings/layout/SettingsShell.js";
|
|
10
11
|
import { useApiClient } from "../../../../lib/util/apiClientContext.js";
|
|
11
12
|
import Button from "../../../../lib/components/buttons/Button.js";
|
|
@@ -169,22 +170,17 @@ function ProfileContent() {
|
|
|
169
170
|
|
|
170
171
|
<FormField label="Time zone" htmlFor="profile-time-zone">
|
|
171
172
|
<div className="flex flex-wrap items-center gap-2">
|
|
172
|
-
<
|
|
173
|
+
<TimeZonePicker
|
|
173
174
|
id="profile-time-zone"
|
|
174
175
|
className={SELECT_CLASS}
|
|
175
176
|
value={timezone}
|
|
176
|
-
|
|
177
|
-
|
|
177
|
+
zones={zones}
|
|
178
|
+
onChange={(zone) => {
|
|
179
|
+
setTimezone(zone);
|
|
178
180
|
setSaved(false);
|
|
179
181
|
}}
|
|
180
182
|
aria-describedby="profile-time-zone-help"
|
|
181
|
-
|
|
182
|
-
{zones.map((zone) => (
|
|
183
|
-
<option key={zone} value={zone}>
|
|
184
|
-
{zone}
|
|
185
|
-
</option>
|
|
186
|
-
))}
|
|
187
|
-
</select>
|
|
183
|
+
/>
|
|
188
184
|
{timezone !== deviceZone && (
|
|
189
185
|
<Button
|
|
190
186
|
type="button"
|
|
@@ -195,7 +191,7 @@ function ProfileContent() {
|
|
|
195
191
|
setSaved(false);
|
|
196
192
|
}}
|
|
197
193
|
>
|
|
198
|
-
Use this device’s time zone ({deviceZone})
|
|
194
|
+
Use this device’s time zone ({describeTimeZone(deviceZone)})
|
|
199
195
|
</Button>
|
|
200
196
|
)}
|
|
201
197
|
</div>
|
|
@@ -13,6 +13,7 @@ import ResourceSettingsCard from "../../shared/components/admin/mailboxes/Resour
|
|
|
13
13
|
import EscrowScopeCard from "../../shared/components/admin/mailboxes/EscrowScopeCard.js";
|
|
14
14
|
import Alert from "../../../lib/components/feedback/Alert.js";
|
|
15
15
|
import Button from "../../../lib/components/buttons/Button.js";
|
|
16
|
+
import { describeTimeZone } from "../../../lib/util/timeZone.js";
|
|
16
17
|
import Modal from "../../../lib/components/overlays/Modal.js";
|
|
17
18
|
function formatBytes(bytes) {
|
|
18
19
|
if (bytes >= 1000000000)
|
|
@@ -107,7 +108,7 @@ function MailboxDetailContent({ uid, impersonationBaseUrl, currentUserUid, }) {
|
|
|
107
108
|
const ingestQueueHref = `/admin/ingest-queue?mailboxUid=${encodeURIComponent(mailbox.uid)}`;
|
|
108
109
|
return (_jsxs("div", { className: "max-w-3xl flex flex-col gap-5", children: [_jsxs("div", { className: "flex items-start justify-between gap-4", children: [_jsxs("div", { children: [_jsx("a", { href: "/admin", className: "text-sm text-primary-dark hover:underline", children: "\u2190 All mailboxes" }), _jsx("h1", { className: "text-xl font-bold tracking-tight mt-1", children: mailbox.primarySmtpAddress })] }), _jsxs("div", { className: "flex gap-3 shrink-0", children: [mailbox.ownerUserUid && (_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", onClick: () => setConfirmingAccess(true), children: "Impersonate this user" })), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto !border-danger !text-danger hover:!border-danger hover:!text-danger", onClick: () => setConfirmingDelete(true), children: "Delete mailbox" })] })] }), _jsxs("p", { className: "text-sm text-text-muted", children: ["The console shows administrative details only - never a mailbox\u2019s mail or settings.", mailbox.ownerUserUid
|
|
109
110
|
? " To see this mailbox as its owner sees it, impersonate them: that is recorded, and you can stop at any time."
|
|
110
|
-
: " This shared mailbox has no owner: add yourself under Shared access to open it in the mail client."] }), _jsxs("div", { className: "bg-surface border border-border rounded-md p-6", children: [_jsxs("dl", { className: "grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-3 text-sm", children: [_jsx("dt", { className: "text-text-muted", children: "Display name" }), _jsx("dd", { children: mailbox.displayName }), _jsx("dt", { className: "text-text-muted", children: "Owner" }), _jsx("dd", { children: mailbox.ownerUserUid ?? "None (shared mailbox)" }), _jsx("dt", { className: "text-text-muted", children: "Timezone" }), _jsx("dd", { children: mailbox.timezone }), _jsx("dt", { className: "text-text-muted", children: "Quota" }), _jsxs("dd", { children: [formatBytes(mailbox.usedBytes), " / ", formatBytes(mailbox.quotaBytes)] }), _jsx("dt", { className: "text-text-muted", children: "Alias addresses" }), _jsx("dd", { children: mailbox.aliasAddresses.length > 0 ? mailbox.aliasAddresses.join(", ") : "None" }), mailbox.isResource && (_jsxs(_Fragment, { children: [_jsx("dt", { className: "text-text-muted", children: "Resource type" }), _jsx("dd", { className: "capitalize", children: mailbox.resourceType ?? "room" })] })), _jsx("dt", { className: "text-text-muted", children: "Created" }), _jsx("dd", { children: new Date(mailbox.dateCreated).toLocaleString() })] }), _jsxs("div", { className: "flex gap-4 mt-5 pt-5 border-t border-border text-sm font-medium", children: [_jsx("a", { href: quarantineHref, className: "text-primary-dark hover:underline", children: "View quarantine" }), _jsx("a", { href: ingestQueueHref, className: "text-primary-dark hover:underline", children: "View ingest queue" })] })] }), _jsx(ShareAccessCard, { mailboxUid: mailbox.uid, ownerUserUid: mailbox.ownerUserUid, currentUserUid: currentUserUid }), _jsx(EscrowScopeCard, { mailbox: mailbox, onUpdate: setMailbox }), mailbox.isResource && _jsx(ResourceSettingsCard, { mailbox: mailbox, onUpdate: setMailbox }), _jsxs(Modal, { open: confirmingAccess, onClose: closeAccessModal, title: "Impersonate this user", children: [_jsxs("p", { className: "text-sm mb-3", children: ["You\u2019ll be signed in as ", _jsx("strong", { className: "break-all", children: mailbox.ownerUserUid }), ", the owner of ", _jsx("strong", { className: "break-all", children: mailbox.primarySmtpAddress }), ", and see everything they can - and act as them, until you stop impersonating. This is the only way an administrator sees another user\u2019s mail."] }), accessError && _jsx(Alert, { children: accessError }), _jsxs("div", { className: "flex gap-3 justify-end mt-5", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", disabled: impersonating, onClick: closeAccessModal, children: "Cancel" }), _jsx(Button, { type: "button", className: "!w-auto", loading: impersonating, disabled: impersonating, onClick: handleAccessMailbox, children: "Impersonate" })] })] }), _jsxs(Modal, { open: confirmingDelete, onClose: closeDeleteModal, title: "Delete mailbox", children: [_jsxs("p", { className: "text-sm mb-3", children: ["Are you sure you want to delete ", _jsx("strong", { className: "break-all", children: mailbox.primarySmtpAddress }), "? This cannot be undone. It fails if this mailbox is a custodian on an active legal hold."] }), _jsxs("p", { className: "text-sm mb-3", children: ["Deleting removes the mailbox itself. Its mail, contacts, calendars and everything else in it are", " ", _jsx("strong", { children: "kept" }), " until they are erased, and until then the address can\u2019t be used for a new mailbox. You can erase them later from the Mailboxes page."] }), _jsxs("label", { className: "flex items-start gap-2 text-sm mb-3", children: [_jsx("input", { type: "checkbox", className: "mt-0.5", checked: eraseData, disabled: deleting, onChange: (e) => {
|
|
111
|
+
: " This shared mailbox has no owner: add yourself under Shared access to open it in the mail client."] }), _jsxs("div", { className: "bg-surface border border-border rounded-md p-6", children: [_jsxs("dl", { className: "grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-3 text-sm", children: [_jsx("dt", { className: "text-text-muted", children: "Display name" }), _jsx("dd", { children: mailbox.displayName }), _jsx("dt", { className: "text-text-muted", children: "Owner" }), _jsx("dd", { children: mailbox.ownerUserUid ?? "None (shared mailbox)" }), _jsx("dt", { className: "text-text-muted", children: "Timezone" }), _jsx("dd", { children: describeTimeZone(mailbox.timezone) }), _jsx("dt", { className: "text-text-muted", children: "Quota" }), _jsxs("dd", { children: [formatBytes(mailbox.usedBytes), " / ", formatBytes(mailbox.quotaBytes)] }), _jsx("dt", { className: "text-text-muted", children: "Alias addresses" }), _jsx("dd", { children: mailbox.aliasAddresses.length > 0 ? mailbox.aliasAddresses.join(", ") : "None" }), mailbox.isResource && (_jsxs(_Fragment, { children: [_jsx("dt", { className: "text-text-muted", children: "Resource type" }), _jsx("dd", { className: "capitalize", children: mailbox.resourceType ?? "room" })] })), _jsx("dt", { className: "text-text-muted", children: "Created" }), _jsx("dd", { children: new Date(mailbox.dateCreated).toLocaleString() })] }), _jsxs("div", { className: "flex gap-4 mt-5 pt-5 border-t border-border text-sm font-medium", children: [_jsx("a", { href: quarantineHref, className: "text-primary-dark hover:underline", children: "View quarantine" }), _jsx("a", { href: ingestQueueHref, className: "text-primary-dark hover:underline", children: "View ingest queue" })] })] }), _jsx(ShareAccessCard, { mailboxUid: mailbox.uid, ownerUserUid: mailbox.ownerUserUid, currentUserUid: currentUserUid }), _jsx(EscrowScopeCard, { mailbox: mailbox, onUpdate: setMailbox }), mailbox.isResource && _jsx(ResourceSettingsCard, { mailbox: mailbox, onUpdate: setMailbox }), _jsxs(Modal, { open: confirmingAccess, onClose: closeAccessModal, title: "Impersonate this user", children: [_jsxs("p", { className: "text-sm mb-3", children: ["You\u2019ll be signed in as ", _jsx("strong", { className: "break-all", children: mailbox.ownerUserUid }), ", the owner of ", _jsx("strong", { className: "break-all", children: mailbox.primarySmtpAddress }), ", and see everything they can - and act as them, until you stop impersonating. This is the only way an administrator sees another user\u2019s mail."] }), accessError && _jsx(Alert, { children: accessError }), _jsxs("div", { className: "flex gap-3 justify-end mt-5", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", disabled: impersonating, onClick: closeAccessModal, children: "Cancel" }), _jsx(Button, { type: "button", className: "!w-auto", loading: impersonating, disabled: impersonating, onClick: handleAccessMailbox, children: "Impersonate" })] })] }), _jsxs(Modal, { open: confirmingDelete, onClose: closeDeleteModal, title: "Delete mailbox", children: [_jsxs("p", { className: "text-sm mb-3", children: ["Are you sure you want to delete ", _jsx("strong", { className: "break-all", children: mailbox.primarySmtpAddress }), "? This cannot be undone. It fails if this mailbox is a custodian on an active legal hold."] }), _jsxs("p", { className: "text-sm mb-3", children: ["Deleting removes the mailbox itself. Its mail, contacts, calendars and everything else in it are", " ", _jsx("strong", { children: "kept" }), " until they are erased, and until then the address can\u2019t be used for a new mailbox. You can erase them later from the Mailboxes page."] }), _jsxs("label", { className: "flex items-start gap-2 text-sm mb-3", children: [_jsx("input", { type: "checkbox", className: "mt-0.5", checked: eraseData, disabled: deleting, onChange: (e) => {
|
|
111
112
|
setEraseData(e.target.checked);
|
|
112
113
|
setTypedAddress("");
|
|
113
114
|
} }), _jsx("span", { children: "Also erase all of its data now (permanent)" })] }), eraseData && (_jsxs("div", { className: "mb-3", children: [_jsx("p", { className: "text-sm font-semibold text-danger mb-2", children: "Everything in this mailbox will be permanently erased, and this cannot be undone." }), _jsxs("label", { className: "flex flex-col gap-1.5 text-sm", children: [_jsxs("span", { className: "font-semibold", children: ["Type ", _jsx("span", { className: "break-all", children: mailbox.primarySmtpAddress }), " to confirm"] }), _jsx("input", { "aria-label": "Type the address to confirm", className: "w-full text-sm py-2.5 px-3 border border-border rounded-sm bg-surface text-text focus:outline-none focus:border-primary", value: typedAddress, disabled: deleting, autoComplete: "off", spellCheck: false, onChange: (e) => setTypedAddress(e.target.value) })] })] })), deleteError && _jsx(Alert, { children: deleteError }), _jsxs("div", { className: "flex gap-3 justify-end mt-5", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", disabled: deleting, onClick: closeDeleteModal, children: "Cancel" }), _jsx(Button, { type: "button", className: "!w-auto !bg-none !bg-danger !border-danger hover:!bg-danger", loading: deleting, disabled: deleting || (eraseData && typedAddress.trim().toLowerCase() !== mailbox.primarySmtpAddress.trim().toLowerCase()), onClick: handleDelete, children: eraseData ? "Delete and erase all its data" : "Delete" })] })] })] }));
|
|
@@ -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
|
}
|
|
@@ -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. */
|