@rapidmx/web-client 0.23.0 → 0.25.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/apps/admin/mailboxes/[uid].tsx +2 -1
  2. package/apps/shared/components/admin/layout/AdminShell.tsx +39 -4
  3. package/apps/shared/components/admin/layout/railPreference.ts +25 -0
  4. package/apps/shared/components/admin/settings/MailboxCreateForm.tsx +2 -1
  5. package/apps/shared/components/calendar/AppointmentQuickForm.tsx +2 -1
  6. package/apps/shared/components/calendar/CalendarListSidebar.tsx +6 -2
  7. package/apps/shared/components/calendar/EventDescriptionView.tsx +37 -5
  8. package/apps/shared/components/calendar/EventDetails.tsx +15 -6
  9. package/apps/shared/components/calendar/EventEditor.tsx +5 -2
  10. package/apps/shared/components/calendar/EventFormParts.tsx +45 -39
  11. package/apps/shared/components/calendar/EventQuickForm.tsx +3 -2
  12. package/apps/shared/components/calendar/FindATime.tsx +3 -2
  13. package/apps/shared/components/calendar/TimeComboBox.tsx +143 -0
  14. package/apps/shared/components/calendar/eventFormat.ts +15 -0
  15. package/apps/shared/components/calendar/timePicker.ts +106 -0
  16. package/apps/shared/components/layout/ThemeSwitch.tsx +3 -2
  17. package/apps/shared/components/layout/UserMenu.tsx +30 -10
  18. package/apps/shared/components/mail/invite/inviteFormat.ts +6 -5
  19. package/apps/shared/components/mail/layout/MailShell.tsx +13 -5
  20. package/apps/shared/components/mail/layout/folderIcons.tsx +54 -0
  21. package/apps/shared/components/settings/layout/SettingsShell.tsx +32 -13
  22. package/apps/shared/navigation/appHrefs.ts +3 -3
  23. package/apps/www/calendar/index.tsx +5 -2
  24. package/apps/www/settings/profile/index.tsx +8 -12
  25. package/dist/apps/admin/mailboxes/[uid].js +2 -1
  26. package/dist/apps/shared/components/admin/layout/AdminShell.js +21 -4
  27. package/dist/apps/shared/components/admin/layout/railPreference.d.ts +6 -0
  28. package/dist/apps/shared/components/admin/layout/railPreference.js +24 -0
  29. package/dist/apps/shared/components/admin/settings/MailboxCreateForm.js +2 -2
  30. package/dist/apps/shared/components/calendar/AppointmentQuickForm.js +2 -1
  31. package/dist/apps/shared/components/calendar/CalendarListSidebar.js +3 -2
  32. package/dist/apps/shared/components/calendar/EventDescriptionView.d.ts +6 -1
  33. package/dist/apps/shared/components/calendar/EventDescriptionView.js +34 -4
  34. package/dist/apps/shared/components/calendar/EventDetails.js +4 -4
  35. package/dist/apps/shared/components/calendar/EventEditor.js +5 -2
  36. package/dist/apps/shared/components/calendar/EventFormParts.d.ts +1 -1
  37. package/dist/apps/shared/components/calendar/EventFormParts.js +9 -3
  38. package/dist/apps/shared/components/calendar/EventQuickForm.js +3 -2
  39. package/dist/apps/shared/components/calendar/FindATime.js +3 -2
  40. package/dist/apps/shared/components/calendar/TimeComboBox.d.ts +25 -0
  41. package/dist/apps/shared/components/calendar/TimeComboBox.js +77 -0
  42. package/dist/apps/shared/components/calendar/eventFormat.d.ts +4 -0
  43. package/dist/apps/shared/components/calendar/eventFormat.js +13 -0
  44. package/dist/apps/shared/components/calendar/timePicker.d.ts +31 -0
  45. package/dist/apps/shared/components/calendar/timePicker.js +89 -0
  46. package/dist/apps/shared/components/layout/ThemeSwitch.js +2 -2
  47. package/dist/apps/shared/components/layout/UserMenu.d.ts +1 -2
  48. package/dist/apps/shared/components/layout/UserMenu.js +4 -4
  49. package/dist/apps/shared/components/mail/invite/inviteFormat.js +6 -5
  50. package/dist/apps/shared/components/mail/layout/MailShell.js +5 -4
  51. package/dist/apps/shared/components/mail/layout/folderIcons.d.ts +8 -0
  52. package/dist/apps/shared/components/mail/layout/folderIcons.js +25 -0
  53. package/dist/apps/shared/components/settings/layout/SettingsShell.d.ts +3 -0
  54. package/dist/apps/shared/components/settings/layout/SettingsShell.js +16 -15
  55. package/dist/apps/shared/navigation/appHrefs.d.ts +3 -3
  56. package/dist/apps/shared/navigation/appHrefs.js +3 -3
  57. package/dist/apps/www/calendar/index.js +5 -2
  58. package/dist/apps/www/settings/profile/index.js +6 -5
  59. package/dist/lib/auth/session.d.ts +14 -1
  60. package/dist/lib/auth/session.js +40 -3
  61. package/dist/lib/components/pickers/TimeZonePicker.d.ts +20 -0
  62. package/dist/lib/components/pickers/TimeZonePicker.js +109 -0
  63. package/dist/lib/util/api.d.ts +6 -0
  64. package/dist/lib/util/api.js +22 -1
  65. package/dist/lib/util/timeZone.d.ts +19 -1
  66. package/dist/lib/util/timeZone.js +59 -2
  67. package/lib/auth/session.ts +48 -3
  68. package/lib/components/pickers/TimeZonePicker.tsx +185 -0
  69. package/lib/util/api.ts +24 -1
  70. package/lib/util/timeZone.ts +65 -2
  71. package/package.json +1 -1
@@ -12,6 +12,7 @@ import ResourceSettingsCard from "../../shared/components/admin/mailboxes/Resour
12
12
  import EscrowScopeCard from "../../shared/components/admin/mailboxes/EscrowScopeCard.js";
13
13
  import Alert from "../../../lib/components/feedback/Alert.js";
14
14
  import Button from "../../../lib/components/buttons/Button.js";
15
+ import { describeTimeZone } from "../../../lib/util/timeZone.js";
15
16
  import Modal from "../../../lib/components/overlays/Modal.js";
16
17
 
17
18
  function formatBytes(bytes: number): string {
@@ -156,7 +157,7 @@ function MailboxDetailContent({
156
157
  <dt className="text-text-muted">Owner</dt>
157
158
  <dd>{mailbox.ownerUserUid ?? "None (shared mailbox)"}</dd>
158
159
  <dt className="text-text-muted">Timezone</dt>
159
- <dd>{mailbox.timezone}</dd>
160
+ <dd>{describeTimeZone(mailbox.timezone)}</dd>
160
161
  <dt className="text-text-muted">Quota</dt>
161
162
  <dd>
162
163
  {formatBytes(mailbox.usedBytes)} / {formatBytes(mailbox.quotaBytes)}
@@ -24,6 +24,8 @@ import {
24
24
  HiOutlineUserGroup,
25
25
  HiOutlineWrenchScrewdriver,
26
26
  HiOutlineBars3,
27
+ HiOutlineChevronDoubleLeft,
28
+ HiOutlineChevronDoubleRight,
27
29
  } from "react-icons/hi2";
28
30
  import { apiFetch, ApiRequestError } from "../../../../../lib/util/api.js";
29
31
  import { getSetupStatus } from "../../../../../lib/admin/setupApi.js";
@@ -35,6 +37,7 @@ import Drawer from "../../../../../lib/components/overlays/Drawer.js";
35
37
  import type { NavItem } from "../../../../../lib/components/navigation/BottomTabBar.js";
36
38
  import { FrameBrandingFooter, useBrandingHtml } from "../../layout/BrandingChrome.js";
37
39
  import RailIcon from "../../layout/RailIcon.js";
40
+ import { readRailExpanded, writeRailExpanded } from "./railPreference.js";
38
41
  import AppearanceProvider from "../../../appearance/AppearanceProvider.js";
39
42
  import UserMenu from "../../layout/UserMenu.js";
40
43
  import {
@@ -192,6 +195,15 @@ export default function AdminShell({ active, userUid, authServerUrl, pluginNav,
192
195
  const [status, setStatus] = useState<Status>("checking");
193
196
  const [error, setError] = useState<string | null>(null);
194
197
  const [menuOpen, setMenuOpen] = useState(false);
198
+ // The side rail shows icons alone, or icons with their labels. Read after the first render: the page is drawn on the server first, which cannot know.
199
+ const [railExpanded, setRailExpanded] = useState(false);
200
+ useEffect(() => setRailExpanded(readRailExpanded()), []);
201
+ function toggleRail() {
202
+ setRailExpanded((expanded) => {
203
+ writeRailExpanded(!expanded);
204
+ return !expanded;
205
+ });
206
+ }
195
207
  // `branding` only feeds the footer below: the admin-configured header is for the webmail and public pages, not the
196
208
  // console. `useBranding()` is still what injects the custom stylesheet and supplies the rail's icon.
197
209
  const { branding, iconSrc } = useBranding();
@@ -309,7 +321,10 @@ export default function AdminShell({ active, userUid, authServerUrl, pluginNav,
309
321
  <div className="flex-1 flex min-h-0">
310
322
  <nav
311
323
  aria-label="Admin sections"
312
- className="hidden md:flex w-16 shrink-0 bg-surface border-r border-border flex-col items-center pb-3 gap-1"
324
+ className={[
325
+ "hidden md:flex shrink-0 bg-surface border-r border-border flex-col pb-3 gap-1",
326
+ railExpanded ? "w-56 items-stretch" : "w-16 items-center",
327
+ ].join(" ")}
313
328
  >
314
329
  <RailIcon src={iconSrc} />
315
330
  {navItems.map(({ id, href, label, icon: Icon }) => (
@@ -318,17 +333,37 @@ export default function AdminShell({ active, userUid, authServerUrl, pluginNav,
318
333
  href={href}
319
334
  aria-label={label}
320
335
  aria-current={id === active ? "page" : undefined}
321
- title={label}
336
+ title={railExpanded ? undefined : label}
322
337
  className={[
323
- "w-10 h-10 flex items-center justify-center rounded-sm",
338
+ "h-10 flex items-center rounded-sm",
339
+ railExpanded ? "gap-3 px-3 mx-2 text-sm font-medium" : "w-10 justify-center",
324
340
  id === active
325
341
  ? "bg-primary/10 text-primary-dark"
326
342
  : "text-text-muted hover:bg-surface-alt hover:text-text",
327
343
  ].join(" ")}
328
344
  >
329
- <Icon size={20} aria-hidden="true" />
345
+ <Icon size={20} aria-hidden="true" className="shrink-0" />
346
+ {railExpanded && <span className="truncate">{label}</span>}
330
347
  </a>
331
348
  ))}
349
+ <button
350
+ type="button"
351
+ aria-expanded={railExpanded}
352
+ aria-label={railExpanded ? "Collapse the menu" : "Expand the menu"}
353
+ title={railExpanded ? "Collapse the menu" : "Expand the menu"}
354
+ onClick={toggleRail}
355
+ className={[
356
+ "mt-auto h-10 flex items-center rounded-sm text-text-muted hover:bg-surface-alt hover:text-text",
357
+ railExpanded ? "gap-3 px-3 mx-2 text-sm font-medium" : "w-10 justify-center",
358
+ ].join(" ")}
359
+ >
360
+ {railExpanded ? (
361
+ <HiOutlineChevronDoubleLeft size={20} aria-hidden="true" className="shrink-0" />
362
+ ) : (
363
+ <HiOutlineChevronDoubleRight size={20} aria-hidden="true" className="shrink-0" />
364
+ )}
365
+ {railExpanded && <span>Collapse</span>}
366
+ </button>
332
367
  </nav>
333
368
  {/* Below `md` the sections are a menu that slides in from the left: ten or so of them don't fit a bar along the bottom. */}
334
369
  <Drawer open={menuOpen} onClose={() => setMenuOpen(false)} title="Admin" fullScreen>
@@ -0,0 +1,25 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+
6
+ /** Where this browser remembers whether the admin console's side rail is showing its labels. */
7
+ export const RAIL_EXPANDED_KEY = "rapidmx:admin-rail-expanded";
8
+
9
+ /** Whether the rail was left expanded. A missing or blocked store means collapsed, as it always was. */
10
+ export function readRailExpanded(): boolean {
11
+ try {
12
+ return localStorage.getItem(RAIL_EXPANDED_KEY) === "true";
13
+ } catch {
14
+ return false;
15
+ }
16
+ }
17
+
18
+ /** Remembers the rail's state. Best effort: a blocked store only means it starts collapsed the next time. */
19
+ export function writeRailExpanded(expanded: boolean): void {
20
+ try {
21
+ localStorage.setItem(RAIL_EXPANDED_KEY, String(expanded));
22
+ } catch {
23
+ // Nothing to do.
24
+ }
25
+ }
@@ -7,7 +7,7 @@ import { ApiRequestError } from "../../../../../lib/util/api.js";
7
7
  import { getMailboxPolicy } from "../../../../../lib/admin/mailboxPolicyApi.js";
8
8
  import { LeftoverConflict, leftoverConflictOf } from "../../../../../lib/admin/leftoverMailboxApi.js";
9
9
  import { createMailbox, listMailboxDomains, Mailbox, resolveMailboxOwner, ResolvedPrincipal } from "../../../../../lib/mail/mailApi.js";
10
- import { deviceTimeZone } from "../../../../../lib/util/timeZone.js";
10
+ import { describeTimeZone, deviceTimeZone, zoneClock } from "../../../../../lib/util/timeZone.js";
11
11
  import { useApiClient } from "../../../../../lib/util/apiClientContext.js";
12
12
  import Alert from "../../../../../lib/components/feedback/Alert.js";
13
13
  import Button from "../../../../../lib/components/buttons/Button.js";
@@ -298,6 +298,7 @@ export default function MailboxCreateForm({ onCreated, defaults, submitLabel = "
298
298
  onChange={(e) => setTimezone(e.target.value)}
299
299
  placeholder="America/Los_Angeles"
300
300
  />
301
+ {zoneClock(timezone) && <p className="text-xs text-text-muted mt-1">{describeTimeZone(timezone)}</p>}
301
302
  </FormField>
302
303
 
303
304
  <FormField label="Quota (GB)" htmlFor="quotaGb">
@@ -19,6 +19,7 @@ import {
19
19
  import { EventFormController } from "./eventForm.js";
20
20
  import { CalendarField, INPUT_CLASS, IconRow, SELECT_CLASS } from "./EventFormParts.js";
21
21
  import QuickFaceFrame from "./QuickFaceFrame.js";
22
+ import { describeTimeZone } from "../../../../lib/util/timeZone.js";
22
23
 
23
24
  const DAY_NAMES = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
24
25
  const DURATIONS = ["15", "30", "45", "60"];
@@ -298,7 +299,7 @@ export default function AppointmentQuickForm({ c, draft, onDraftChange, tabs, se
298
299
  onChange={(e) => onDraftChange({ endTime: e.target.value })}
299
300
  />
300
301
  </div>
301
- <p className="text-xs text-text-muted mt-1">Times are in {timeZone.replace(/_/g, " ")}.</p>
302
+ <p className="text-xs text-text-muted mt-1">Times are in {describeTimeZone(timeZone)}.</p>
302
303
  </IconRow>
303
304
 
304
305
  <IconRow icon={<HiOutlineMapPin size={20} />}>
@@ -7,6 +7,7 @@ import { CALENDAR_COLOR_PALETTE, accentColorForMailbox, colorForFolder } from ".
7
7
  import { Folder, Mailbox } from "../../../../lib/mail/mailApi.js";
8
8
  import Alert from "../../../../lib/components/feedback/Alert.js";
9
9
  import { notifyApiError } from "../../notifications/apiErrors.js";
10
+ import { SharedMailboxMark } from "../mail/layout/folderIcons.js";
10
11
 
11
12
  export interface CalendarListSidebarMailbox {
12
13
  mailbox: Mailbox;
@@ -108,7 +109,7 @@ export default function CalendarListSidebar({
108
109
  return (
109
110
  <nav aria-label="My calendars" className="p-3 border-t border-border flex flex-col gap-3">
110
111
  {mailboxCalendars.map(({ mailbox, calendarFolders, error: mailboxError }) => {
111
- const title = multiple ? `${mailbox.displayName}${mailbox.ownerUserUid ? "" : " (shared)"}` : "My calendars";
112
+ const title = multiple ? mailbox.displayName : "My calendars";
112
113
  return (
113
114
  <div key={mailbox.uid}>
114
115
  <div className="flex items-center justify-between mb-1 gap-2">
@@ -120,7 +121,10 @@ export default function CalendarListSidebar({
120
121
  style={{ backgroundColor: accentColorForMailbox(mailbox.uid) }}
121
122
  />
122
123
  )}
123
- <span className="truncate">{title}</span>
124
+ <span className="truncate">
125
+ {title}
126
+ {multiple && !mailbox.ownerUserUid && <SharedMailboxMark />}
127
+ </span>
124
128
  </h2>
125
129
  <button
126
130
  type="button"
@@ -5,18 +5,50 @@
5
5
  import React, { ReactNode } from "react";
6
6
  import { DescriptionNode, htmlToPlainText, parseEventDescription } from "../../../../lib/calendar/eventDescription.js";
7
7
 
8
- function renderNodes(nodes: DescriptionNode[]): ReactNode[] {
8
+ const URL_PATTERN = /https?:\/\/[^\s<>"']+/gi;
9
+ const LINK_CLASS = "text-primary-dark underline break-words";
10
+
11
+ function count(text: string, character: string): number {
12
+ return text.split(character).length - 1;
13
+ }
14
+
15
+ /**
16
+ * `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
17
+ * 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.
18
+ */
19
+ export function linkifyText(text: string): ReactNode[] {
20
+ const parts: ReactNode[] = [];
21
+ let last = 0;
22
+ for (const match of text.matchAll(URL_PATTERN)) {
23
+ let url = match[0];
24
+ while (/[.,;:!?]$/.test(url) || (url.endsWith(")") && count(url, ")") > count(url, "(")) || (url.endsWith("]") && count(url, "]") > count(url, "["))) {
25
+ url = url.slice(0, -1);
26
+ }
27
+ const at = match.index;
28
+ parts.push(text.slice(last, at));
29
+ parts.push(
30
+ <a key={at} href={url} target="_blank" rel="noopener noreferrer" className={LINK_CLASS}>
31
+ {url}
32
+ </a>,
33
+ );
34
+ last = at + url.length;
35
+ }
36
+ parts.push(text.slice(last));
37
+ return parts;
38
+ }
39
+
40
+ function renderNodes(nodes: DescriptionNode[], insideLink = false): ReactNode[] {
9
41
  return nodes.map((node, index) => {
10
42
  if ("text" in node) {
11
- return node.text;
43
+ return insideLink ? node.text : <React.Fragment key={index}>{linkifyText(node.text)}</React.Fragment>;
12
44
  }
13
45
  switch (node.tag) {
14
46
  case "br":
15
47
  return <br key={index} />;
16
48
  case "a":
17
49
  return (
18
- <a key={index} href={node.href} target="_blank" rel="noopener noreferrer" className="text-primary-dark underline break-words">
19
- {renderNodes(node.children)}
50
+ <a key={index} href={node.href} target="_blank" rel="noopener noreferrer" className={LINK_CLASS}>
51
+ {renderNodes(node.children, true)}
20
52
  </a>
21
53
  );
22
54
  case "ul":
@@ -66,5 +98,5 @@ export default function EventDescriptionView({ html, text, className }: EventDes
66
98
  if (htmlToPlainText(html) !== "") {
67
99
  return <div className={["break-words", className].filter(Boolean).join(" ")}>{renderNodes(parseEventDescription(html))}</div>;
68
100
  }
69
- return text?.trim() ? <div className={["break-words whitespace-pre-wrap", className].filter(Boolean).join(" ")}>{text}</div> : null;
101
+ return text?.trim() ? <div className={["break-words whitespace-pre-wrap", className].filter(Boolean).join(" ")}>{linkifyText(text)}</div> : null;
70
102
  }
@@ -22,13 +22,13 @@ import { htmlToPlainText } from "../../../../lib/calendar/eventDescription.js";
22
22
  import { deleteEventOccurrence, deleteEventSeries } from "../../../../lib/calendar/calendarMutations.js";
23
23
  import { CalendarOccurrence, describeRecurrence } from "../../../../lib/calendar/recurrence.js";
24
24
  import { getVideoMeeting } from "../../../../lib/videoconf/videoMeetingsApi.js";
25
- import { deviceTimeZone } from "../../../../lib/util/timeZone.js";
25
+ import { describeTimeZone, deviceTimeZone } from "../../../../lib/util/timeZone.js";
26
26
  import Alert from "../../../../lib/components/feedback/Alert.js";
27
27
  import Button from "../../../../lib/components/buttons/Button.js";
28
28
  import { joinMeetingUrl } from "../../calendar/calendarReminders.js";
29
29
  import EventDescriptionView from "./EventDescriptionView.js";
30
30
  import RequestChangeForm from "./RequestChangeForm.js";
31
- import { BUSY_STATUS_LABEL, RESPONSE_STATUS_LABEL, VISIBILITY_LABEL, describeGuestPermissions, describeReminder, formatStoredWhen } from "./eventFormat.js";
31
+ import { BUSY_STATUS_LABEL, RESPONSE_STATUS_LABEL, VISIBILITY_LABEL, describeGuestPermissions, describeReminder, formatStoredWhen, formatStoredWhenInZone } from "./eventFormat.js";
32
32
 
33
33
  const ROLE_LABEL = { required: "", optional: "Optional", resource: "Room/equipment" } as const;
34
34
 
@@ -220,11 +220,20 @@ export default function EventDetails({
220
220
  {error && <Alert>{error}</Alert>}
221
221
 
222
222
  <DetailRow icon={<HiOutlineClock size={18} />}>
223
- <div>{formatStoredWhen(occurrence.startDate, occurrence.endDate, occurrence.allDay)}</div>
224
- {occurrence.recurrenceRule && <div className="text-text-muted">Repeats {describeRecurrence(occurrence.recurrenceRule)}</div>}
225
- {!occurrence.allDay && eventZone && eventZone !== deviceTimeZone() && (
226
- <div className="text-text-muted">Event time zone: {eventZone.replace(/_/g, " ")}</div>
223
+ {!occurrence.allDay && eventZone && eventZone !== deviceTimeZone() ? (
224
+ // Set somewhere else: the time on the reader's own clock, and on the clock it was set on, each said in full so nobody has to convert.
225
+ <>
226
+ <div>
227
+ {formatStoredWhen(occurrence.startDate, occurrence.endDate, false)} &bull; {describeTimeZone(deviceTimeZone(), new Date(occurrence.startDate))}
228
+ </div>
229
+ <div className="text-text-muted">
230
+ {formatStoredWhenInZone(occurrence.startDate, occurrence.endDate, eventZone)} &bull; {describeTimeZone(eventZone, new Date(occurrence.startDate))}
231
+ </div>
232
+ </>
233
+ ) : (
234
+ <div>{formatStoredWhen(occurrence.startDate, occurrence.endDate, occurrence.allDay)}</div>
227
235
  )}
236
+ {occurrence.recurrenceRule && <div className="text-text-muted">Repeats {describeRecurrence(occurrence.recurrenceRule)}</div>}
228
237
  </DetailRow>
229
238
 
230
239
  {occurrence.location && (
@@ -34,6 +34,7 @@ import QuickCreateFaces, { QuickCreateConfig } from "./QuickCreateFaces.js";
34
34
  import { EditScope, EventFormController, EventFormValues } from "./eventForm.js";
35
35
  import { descriptionProblem, dialogFields, hasDescriptionText, initialDescriptionHtml } from "./eventDialogFields.js";
36
36
  import { mergeGuests, msToWallString, wallStringToMs } from "./eventFormat.js";
37
+ import { nextHalfHour } from "./timePicker.js";
37
38
 
38
39
  /**
39
40
  * The Location text this modal writes when it mints a video meeting for an event, and the one value it will
@@ -190,8 +191,10 @@ export default function EventEditor({
190
191
  start = `${format(initialStart ?? new Date(), "yyyy-MM-dd")}T00:00`;
191
192
  end = start;
192
193
  } else {
193
- start = toDatetimeLocal(occurrence?.startDate ?? initialStart?.toISOString() ?? new Date().toISOString());
194
- end = toDatetimeLocal(occurrence?.endDate ?? initialEnd?.toISOString() ?? new Date(Date.now() + 30 * MS_PER_MINUTE).toISOString());
194
+ // With nothing clicked, a new event starts at the next half hour and lasts an hour.
195
+ const newStart = initialStart ?? nextHalfHour(new Date());
196
+ start = toDatetimeLocal(occurrence?.startDate ?? newStart.toISOString());
197
+ end = toDatetimeLocal(occurrence?.endDate ?? (initialEnd ?? new Date(newStart.getTime() + MS_PER_HOUR)).toISOString());
195
198
  }
196
199
  return {
197
200
  title: occurrence?.title ?? "",
@@ -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 { EventFormController } from "./eventForm.js";
14
16
  import { hasDescriptionText } from "./eventDialogFields.js";
17
+ import { dayTimes, endTimeOptions, formatTimeOfDay } from "./timePicker.js";
15
18
  import {
16
19
  REMINDER_UNITS,
17
20
  ReminderUnit,
@@ -20,6 +23,7 @@ import {
20
23
  VISIBILITY_HELP,
21
24
  VISIBILITY_LABEL,
22
25
  bestReminderUnit,
26
+ zoneDate,
23
27
  } from "./eventFormat.js";
24
28
 
25
29
  export const INPUT_CLASS =
@@ -378,7 +382,9 @@ export function RecurrenceSelect({
378
382
  );
379
383
  }
380
384
 
381
- /** [start date] [start time] to [end time] [end date], then "All day", the "Time zone" link (and its menu) and how the event repeats. */
385
+ const START_TIME_OPTIONS = dayTimes().map((time) => ({ value: time, label: formatTimeOfDay(time) }));
386
+
387
+ /** [start date] [start time] to [end time] [end date], then the "Time zone" link (and its menu), "All day" and how the event repeats. */
382
388
  export function DateTimeControls({ c }: { c: EventFormController }) {
383
389
  const { values } = c;
384
390
  const [zoneOpen, setZoneOpen] = useState(false);
@@ -393,22 +399,27 @@ export function DateTimeControls({ c }: { c: EventFormController }) {
393
399
  onChange={(e) => c.setStart(`${e.target.value}${values.start.slice(10)}`)}
394
400
  />
395
401
  {!values.allDay && (
396
- <input
397
- type="time"
402
+ <TimeComboBox
398
403
  aria-label="Event start time"
399
- className={`${SELECT_CLASS} min-w-0`}
400
- value={values.start.slice(11, 16)}
401
- onChange={(e) => c.setStart(`${values.start.slice(0, 11)}${e.target.value}`)}
404
+ className={`${SELECT_CLASS} w-28`}
405
+ display={formatTimeOfDay(values.start.slice(11, 16))}
406
+ current={values.start.slice(11, 16)}
407
+ options={START_TIME_OPTIONS}
408
+ // Moving the start moves the end with it, so the event keeps its length: whatever the start is set to.
409
+ onSelect={(time) => c.setStart(`${values.start.slice(0, 11)}${time}`)}
410
+ onType={(time) => c.setStart(`${values.start.slice(0, 11)}${time}`)}
402
411
  />
403
412
  )}
404
413
  <span className="text-text-muted">to</span>
405
414
  {!values.allDay && (
406
- <input
407
- type="time"
415
+ <TimeComboBox
408
416
  aria-label="Event end time"
409
- className={`${SELECT_CLASS} min-w-0`}
410
- value={values.end.slice(11, 16)}
411
- onChange={(e) => c.setEnd(`${values.end.slice(0, 11)}${e.target.value}`)}
417
+ className={`${SELECT_CLASS} w-28`}
418
+ display={formatTimeOfDay(values.end.slice(11, 16))}
419
+ current={values.end}
420
+ options={endTimeOptions(values.start).map((option) => ({ value: option.value, label: option.label, hint: option.hint }))}
421
+ onSelect={c.setEnd}
422
+ onType={(time) => c.setEnd(`${values.end.slice(0, 11)}${time}`)}
412
423
  />
413
424
  )}
414
425
  <input
@@ -419,36 +430,31 @@ export function DateTimeControls({ c }: { c: EventFormController }) {
419
430
  onChange={(e) => c.setEnd(`${e.target.value}${values.end.slice(10)}`)}
420
431
  />
421
432
  </div>
422
- <div className="flex flex-wrap items-center gap-x-4 gap-y-2 text-sm">
423
- <label className="flex items-center gap-2">
424
- <input type="checkbox" checked={values.allDay} onChange={(e) => c.onAllDayChange(e.target.checked)} />
425
- All day
426
- </label>
427
- <button
428
- type="button"
429
- aria-expanded={zoneOpen}
430
- className="text-primary-dark hover:underline"
431
- onClick={() => setZoneOpen((open) => !open)}
432
- >
433
- Time zone
434
- </button>
435
- {!c.editingSingleOccurrence && (
436
- <RecurrenceSelect value={values.recurrenceRule} onChange={(rule) => c.update({ recurrenceRule: rule })} allDay={values.allDay} startWeekday={c.startWeekday} />
437
- )}
438
- </div>
433
+ {/* The Time zone link sits above All day, and the zone menu it opens right under it - before how the event repeats. */}
434
+ <button
435
+ type="button"
436
+ aria-expanded={zoneOpen}
437
+ className="self-start text-sm text-primary-dark hover:underline"
438
+ onClick={() => setZoneOpen((open) => !open)}
439
+ >
440
+ Time zone
441
+ </button>
439
442
  {zoneOpen && (
440
- <select
441
- aria-label="Time zone"
442
- className={`${SELECT_CLASS} self-start max-w-full`}
443
+ <TimeZonePicker
444
+ aria-label="Event time zone"
445
+ className={`${SELECT_CLASS} max-w-full`}
443
446
  value={values.formZone}
444
- onChange={(e) => c.onTimeZoneChange(e.target.value)}
445
- >
446
- {c.timeZones.map((zone) => (
447
- <option key={zone} value={zone}>
448
- {zone}
449
- </option>
450
- ))}
451
- </select>
447
+ zones={c.timeZones}
448
+ at={zoneDate(values.start)}
449
+ onChange={c.onTimeZoneChange}
450
+ />
451
+ )}
452
+ <label className="flex items-center gap-2 text-sm">
453
+ <input type="checkbox" checked={values.allDay} onChange={(e) => c.onAllDayChange(e.target.checked)} />
454
+ All day
455
+ </label>
456
+ {!c.editingSingleOccurrence && (
457
+ <RecurrenceSelect value={values.recurrenceRule} onChange={(rule) => c.update({ recurrenceRule: rule })} allDay={values.allDay} startWeekday={c.startWeekday} />
452
458
  )}
453
459
  </div>
454
460
  );
@@ -9,8 +9,9 @@ import Alert from "../../../../lib/components/feedback/Alert.js";
9
9
  import Button from "../../../../lib/components/buttons/Button.js";
10
10
  import { EventShellContext } from "./EventShell.js";
11
11
  import { CalendarField, DateTimeControls, DescriptionRow, GuestsRow, IconRow, LocationRow, VideoConferencingRow } from "./EventFormParts.js";
12
+ import { describeTimeZone } from "../../../../lib/util/timeZone.js";
12
13
  import { EventFormController } from "./eventForm.js";
13
- import { BUSY_STATUS_LABEL, VISIBILITY_LABEL, describeReminder, formatFormWhen, reminderOf } from "./eventFormat.js";
14
+ import { BUSY_STATUS_LABEL, VISIBILITY_LABEL, describeReminder, formatFormWhen, reminderOf, zoneDate } from "./eventFormat.js";
14
15
 
15
16
  export interface EventQuickFormProps {
16
17
  c: EventFormController;
@@ -79,7 +80,7 @@ export default function EventQuickForm({ c, onExpand, tabs, tabPanelProps }: Eve
79
80
  >
80
81
  <span className="block text-sm">{formatFormWhen(values.start, values.end, values.allDay)}</span>
81
82
  <span className="block text-xs text-text-muted">
82
- {values.formZone.replace(/_/g, " ")} &bull;{" "}
83
+ {describeTimeZone(values.formZone, zoneDate(values.start))} &bull;{" "}
83
84
  {values.recurrenceRule ? `Repeats ${describeRecurrence(values.recurrenceRule)}` : "Does not repeat"}
84
85
  </span>
85
86
  </button>
@@ -19,7 +19,8 @@ import Button from "../../../../lib/components/buttons/Button.js";
19
19
  import { useApiClient } from "../../../../lib/util/apiClientContext.js";
20
20
  import { addDaysToKey } from "./allDay.js";
21
21
  import { EventFormController } from "./eventForm.js";
22
- import { formatFormWhen, localDateFromKey, msToWallString, wallStringToMs } from "./eventFormat.js";
22
+ import { formatFormWhen, localDateFromKey, msToWallString, wallStringToMs, zoneDate } from "./eventFormat.js";
23
+ import { describeTimeZone } from "../../../../lib/util/timeZone.js";
23
24
  import {
24
25
  SEARCH_DAYS,
25
26
  WORK_END_HOUR,
@@ -238,7 +239,7 @@ export default function FindATime({ c }: { c: EventFormController }) {
238
239
  >
239
240
  &rsaquo;
240
241
  </button>
241
- <span className="text-xs text-text-muted">{formZone.replace(/_/g, " ")}</span>
242
+ <span className="text-xs text-text-muted">{describeTimeZone(formZone, zoneDate(dayKey))}</span>
242
243
  </div>
243
244
 
244
245
  {lookUp.status === "error" && (
@@ -0,0 +1,143 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import React, { KeyboardEvent, useEffect, useId, useRef, useState } from "react";
6
+ import { parseTimeInput } from "./timePicker.js";
7
+
8
+ export interface TimeOption {
9
+ value: string;
10
+ label: string;
11
+ /** Said after the label, muted: the length of the event an end time makes. */
12
+ hint?: string;
13
+ }
14
+
15
+ export interface TimeComboBoxProps {
16
+ "aria-label": string;
17
+ /** The time being shown, as it reads in the field. */
18
+ display: string;
19
+ /** The `value` of the option that is the current one, if any of them is. */
20
+ current?: string;
21
+ options: TimeOption[];
22
+ /** An option was picked. */
23
+ onSelect: (value: string) => void;
24
+ /** A time was typed (`HH:mm`, already understood). */
25
+ onType: (time: string) => void;
26
+ className?: string;
27
+ }
28
+
29
+ /**
30
+ * 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" -
31
+ * 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.
32
+ */
33
+ export default function TimeComboBox({ "aria-label": label, display, current, options, onSelect, onType, className }: TimeComboBoxProps) {
34
+ const listId = useId();
35
+ const [draft, setDraft] = useState<string | null>(null);
36
+ const [open, setOpen] = useState(false);
37
+ const [active, setActive] = useState(-1);
38
+ const selectedRef = useRef<HTMLLIElement>(null);
39
+
40
+ // Opening puts the current time in view, as the middle of the list.
41
+ useEffect(() => {
42
+ if (open) {
43
+ selectedRef.current?.scrollIntoView?.({ block: "center" });
44
+ }
45
+ }, [open]);
46
+
47
+ function close() {
48
+ setOpen(false);
49
+ setActive(-1);
50
+ setDraft(null);
51
+ }
52
+
53
+ function commitDraft() {
54
+ const time = draft === null ? null : parseTimeInput(draft);
55
+ if (time !== null) {
56
+ onType(time);
57
+ }
58
+ close();
59
+ }
60
+
61
+ function pick(value: string) {
62
+ onSelect(value);
63
+ close();
64
+ }
65
+
66
+ function handleKeyDown(event: KeyboardEvent<HTMLInputElement>) {
67
+ if (event.key === "ArrowDown" || event.key === "ArrowUp") {
68
+ event.preventDefault();
69
+ setOpen(true);
70
+ setActive((index) => {
71
+ const from = index < 0 ? options.findIndex((option) => option.value === current) : index;
72
+ return Math.min(Math.max(from + (event.key === "ArrowDown" ? 1 : -1), 0), options.length - 1);
73
+ });
74
+ } else if (event.key === "Enter") {
75
+ // Never the form's own submit: Enter here settles the time.
76
+ event.preventDefault();
77
+ if (open && active >= 0) {
78
+ pick(options[active].value);
79
+ } else {
80
+ commitDraft();
81
+ }
82
+ } else if (event.key === "Escape" && open) {
83
+ event.stopPropagation();
84
+ close();
85
+ }
86
+ }
87
+
88
+ return (
89
+ <div className="relative">
90
+ <input
91
+ type="text"
92
+ role="combobox"
93
+ aria-label={label}
94
+ aria-expanded={open}
95
+ aria-controls={listId}
96
+ aria-autocomplete="list"
97
+ aria-activedescendant={active >= 0 ? `${listId}-${active}` : undefined}
98
+ autoComplete="off"
99
+ className={className}
100
+ value={draft ?? display}
101
+ onClick={() => setOpen(true)}
102
+ onFocus={(event) => {
103
+ event.currentTarget.select();
104
+ setOpen(true);
105
+ }}
106
+ onChange={(event) => setDraft(event.target.value)}
107
+ onBlur={commitDraft}
108
+ onKeyDown={handleKeyDown}
109
+ />
110
+ {open && (
111
+ <ul
112
+ id={listId}
113
+ role="listbox"
114
+ aria-label={`${label} options`}
115
+ 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"
116
+ >
117
+ {options.map((option, index) => (
118
+ <li
119
+ key={option.value}
120
+ id={`${listId}-${index}`}
121
+ ref={option.value === current ? selectedRef : undefined}
122
+ role="option"
123
+ aria-selected={option.value === current}
124
+ // Before the field's own blur, which would otherwise close the list and swallow the click.
125
+ onMouseDown={(event) => {
126
+ event.preventDefault();
127
+ pick(option.value);
128
+ }}
129
+ className={[
130
+ "flex cursor-pointer items-baseline gap-1.5 px-3 py-1.5 text-sm hover:bg-surface-alt",
131
+ option.value === current ? "font-semibold text-primary-dark" : "",
132
+ index === active ? "bg-surface-alt" : "",
133
+ ].join(" ")}
134
+ >
135
+ {option.label}
136
+ {option.hint && <span className="text-xs font-normal text-text-muted">{option.hint}</span>}
137
+ </li>
138
+ ))}
139
+ </ul>
140
+ )}
141
+ </div>
142
+ );
143
+ }