@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.
Files changed (48) hide show
  1. package/apps/admin/mailboxes/[uid].tsx +2 -1
  2. package/apps/shared/components/admin/settings/MailboxCreateForm.tsx +2 -1
  3. package/apps/shared/components/calendar/AppointmentQuickForm.tsx +2 -1
  4. package/apps/shared/components/calendar/EventDescriptionView.tsx +37 -5
  5. package/apps/shared/components/calendar/EventDetails.tsx +15 -6
  6. package/apps/shared/components/calendar/EventEditor.tsx +5 -2
  7. package/apps/shared/components/calendar/EventFormParts.tsx +45 -39
  8. package/apps/shared/components/calendar/EventQuickForm.tsx +3 -2
  9. package/apps/shared/components/calendar/FindATime.tsx +3 -2
  10. package/apps/shared/components/calendar/TimeComboBox.tsx +143 -0
  11. package/apps/shared/components/calendar/eventFormat.ts +15 -0
  12. package/apps/shared/components/calendar/timePicker.ts +106 -0
  13. package/apps/shared/components/mail/invite/inviteFormat.ts +6 -5
  14. package/apps/www/calendar/index.tsx +5 -2
  15. package/apps/www/settings/profile/index.tsx +8 -12
  16. package/dist/apps/admin/mailboxes/[uid].js +2 -1
  17. package/dist/apps/shared/components/admin/settings/MailboxCreateForm.js +2 -2
  18. package/dist/apps/shared/components/calendar/AppointmentQuickForm.js +2 -1
  19. package/dist/apps/shared/components/calendar/EventDescriptionView.d.ts +6 -1
  20. package/dist/apps/shared/components/calendar/EventDescriptionView.js +34 -4
  21. package/dist/apps/shared/components/calendar/EventDetails.js +4 -4
  22. package/dist/apps/shared/components/calendar/EventEditor.js +5 -2
  23. package/dist/apps/shared/components/calendar/EventFormParts.d.ts +1 -1
  24. package/dist/apps/shared/components/calendar/EventFormParts.js +9 -3
  25. package/dist/apps/shared/components/calendar/EventQuickForm.js +3 -2
  26. package/dist/apps/shared/components/calendar/FindATime.js +3 -2
  27. package/dist/apps/shared/components/calendar/TimeComboBox.d.ts +25 -0
  28. package/dist/apps/shared/components/calendar/TimeComboBox.js +77 -0
  29. package/dist/apps/shared/components/calendar/eventFormat.d.ts +4 -0
  30. package/dist/apps/shared/components/calendar/eventFormat.js +13 -0
  31. package/dist/apps/shared/components/calendar/timePicker.d.ts +31 -0
  32. package/dist/apps/shared/components/calendar/timePicker.js +89 -0
  33. package/dist/apps/shared/components/mail/invite/inviteFormat.js +6 -5
  34. package/dist/apps/www/calendar/index.js +5 -2
  35. package/dist/apps/www/settings/profile/index.js +6 -5
  36. package/dist/lib/auth/session.d.ts +14 -1
  37. package/dist/lib/auth/session.js +40 -3
  38. package/dist/lib/components/pickers/TimeZonePicker.d.ts +20 -0
  39. package/dist/lib/components/pickers/TimeZonePicker.js +100 -0
  40. package/dist/lib/util/api.d.ts +6 -0
  41. package/dist/lib/util/api.js +22 -1
  42. package/dist/lib/util/timeZone.d.ts +19 -1
  43. package/dist/lib/util/timeZone.js +46 -2
  44. package/lib/auth/session.ts +48 -3
  45. package/lib/components/pickers/TimeZonePicker.tsx +180 -0
  46. package/lib/util/api.ts +24 -1
  47. package/lib/util/timeZone.ts +50 -2
  48. 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 timeAndZone = new Intl.DateTimeFormat(undefined, { ...CLOCK_FORMAT, timeZoneName: "short" });
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}, ${timeAndZone.format(start)}`;
61
+ return `${startDay}, ${time.format(start)} • ${zone}`;
61
62
  }
62
63
  return startDay === date.format(end)
63
- ? `${startDay}, ${time.format(start)} - ${timeAndZone.format(end)}`
64
- : `${startDay}, ${time.format(start)} - ${date.format(end)}, ${timeAndZone.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: start ?? new Date(),
363
- initialEnd: end ?? new Date(Date.now() + 30 * 60_000),
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 { DEFAULT_TIME_ZONE, deviceTimeZone, timeZoneOptions } from "../../../../lib/util/timeZone.js";
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
- <select
173
+ <TimeZonePicker
173
174
  id="profile-time-zone"
174
175
  className={SELECT_CLASS}
175
176
  value={timezone}
176
- onChange={(e) => {
177
- setTimezone(e.target.value);
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&rsquo;s time zone ({deviceZone})
194
+ Use this device&rsquo;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?"] })) })))] })] }), _jsx(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" }) }), _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) => {
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.replace(/_/g, " "), "."] })] }), _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
+ ].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
- function renderNodes(nodes) {
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: "text-primary-dark underline break-words", children: renderNodes(node.children) }, index));
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: [_jsx("div", { children: formatStoredWhen(occurrence.startDate, occurrence.endDate, occurrence.allDay) }), occurrence.recurrenceRule && _jsxs("div", { className: "text-text-muted", children: ["Repeats ", describeRecurrence(occurrence.recurrenceRule)] }), !occurrence.allDay && eventZone && eventZone !== deviceTimeZone() && (_jsxs("div", { className: "text-text-muted", children: ["Event time zone: ", eventZone.replace(/_/g, " ")] }))] }), 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: {
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
- start = toDatetimeLocal(occurrence?.startDate ?? initialStart?.toISOString() ?? new Date().toISOString());
124
- end = toDatetimeLocal(occurrence?.endDate ?? initialEnd?.toISOString() ?? new Date(Date.now() + 30 * MS_PER_MINUTE).toISOString());
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 "All day", the "Time zone" link (and its menu) and how the event repeats. */
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 { REMINDER_UNITS, RESPONSE_STATUS_LABEL, VISIBILITIES, VISIBILITY_HELP, VISIBILITY_LABEL, bestReminderUnit, } from "./eventFormat.js";
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
- /** [start date] [start time] to [end time] [end date], then "All day", the "Time zone" link (and its menu) and how the event repeats. */
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("input", { type: "time", "aria-label": "Event start time", className: `${SELECT_CLASS} min-w-0`, value: values.start.slice(11, 16), onChange: (e) => c.setStart(`${values.start.slice(0, 11)}${e.target.value}`) })), _jsx("span", { className: "text-text-muted", children: "to" }), !values.allDay && (_jsx("input", { type: "time", "aria-label": "Event end time", className: `${SELECT_CLASS} min-w-0`, value: values.end.slice(11, 16), onChange: (e) => c.setEnd(`${values.end.slice(0, 11)}${e.target.value}`) })), _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)}`) })] }), _jsxs("div", { className: "flex flex-wrap items-center gap-x-4 gap-y-2 text-sm", children: [_jsxs("label", { className: "flex items-center gap-2", children: [_jsx("input", { type: "checkbox", checked: values.allDay, onChange: (e) => c.onAllDayChange(e.target.checked) }), "All day"] }), _jsx("button", { type: "button", "aria-expanded": zoneOpen, className: "text-primary-dark hover:underline", onClick: () => setZoneOpen((open) => !open), children: "Time zone" }), !c.editingSingleOccurrence && (_jsx(RecurrenceSelect, { value: values.recurrenceRule, onChange: (rule) => c.update({ recurrenceRule: rule }), allDay: values.allDay, startWeekday: c.startWeekday }))] }), zoneOpen && (_jsx("select", { "aria-label": "Time zone", className: `${SELECT_CLASS} self-start max-w-full`, value: values.formZone, onChange: (e) => c.onTimeZoneChange(e.target.value), children: c.timeZones.map((zone) => (_jsx("option", { value: zone, children: zone }, zone))) }))] }));
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 { BUSY_STATUS_LABEL, VISIBILITY_LABEL, describeReminder, formatFormWhen, reminderOf } from "./eventFormat.js";
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.replace(/_/g, " "), " \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
+ 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.replace(/_/g, " ") })] }), 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
+ 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. */