Calendar panel: a picked mini-calendar day showed nowhere; no new-tab link on a phone

In Mail's side panel the agenda already lists the whole month and the mini
calendar never marked a selection, so clicking a day in the current month
changed nothing on screen. The day is now highlighted and the agenda scrolls to
it (TODAY scrolls back); a day in another month flips both to that month.

On a phone the panel drops the "Open calendar in new tab" footer: the host
passes newTabLink: false to the embedded calendar.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-30 21:23:10 +02:00
co-authored by Claude Opus 5.5
parent a5b0842e6a
commit 0ddfc8c658
2 changed files with 38 additions and 6 deletions
+9 -2
View File
@@ -12,6 +12,7 @@ import { useT } from "../../i18n";
import { api, apiFetch } from "../../lib/api"; import { api, apiFetch } from "../../lib/api";
import type { Address, AttachmentOut, MessageEnvelope, ThreadDetail } from "../../lib/types"; import type { Address, AttachmentOut, MessageEnvelope, ThreadDetail } from "../../lib/types";
import { AvatarImage } from "../SenderAvatar"; import { AvatarImage } from "../SenderAvatar";
import { useIsMobile } from "../../lib/useIsMobile";
import { loadBundle } from "../../plugins/bundleLoader"; import { loadBundle } from "../../plugins/bundleLoader";
import { usePluginContext } from "../../plugins/context"; import { usePluginContext } from "../../plugins/context";
import { isPreviewable } from "../thread/previewKind"; import { isPreviewable } from "../thread/previewKind";
@@ -123,6 +124,8 @@ const CAL_BUNDLE = "/api/plugins/calendar/ui/page.js";
function PanelCalendar({ invite }: { invite: InviteSignal | null }) { function PanelCalendar({ invite }: { invite: InviteSignal | null }) {
const ref = useRef<HTMLDivElement>(null); const ref = useRef<HTMLDivElement>(null);
const ctx = usePluginContext("calendar"); const ctx = usePluginContext("calendar");
// A phone has no use for "Open calendar in new tab".
const mobile = useIsMobile();
// Remount when a new "Invite all" fires so the form opens with those guests. // Remount when a new "Invite all" fires so the form opens with those guests.
const inviteN = invite?.n ?? 0; const inviteN = invite?.n ?? 0;
useEffect(() => { useEffect(() => {
@@ -131,11 +134,15 @@ function PanelCalendar({ invite }: { invite: InviteSignal | null }) {
loadBundle(CAL_BUNDLE).then((mount) => { loadBundle(CAL_BUNDLE).then((mount) => {
if (!alive || !ref.current) return; if (!alive || !ref.current) return;
ref.current.innerHTML = ""; ref.current.innerHTML = "";
cleanup = mount(ref.current, ctx, { embed: "panel", newEventGuests: invite?.guests }); cleanup = mount(ref.current, ctx, {
embed: "panel",
newEventGuests: invite?.guests,
newTabLink: !mobile,
});
}); });
return () => { alive = false; if (typeof cleanup === "function") cleanup(); }; return () => { alive = false; if (typeof cleanup === "function") cleanup(); };
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [ctx, inviteN]); }, [ctx, inviteN, mobile]);
return <div ref={ref} className="h-full" />; return <div ref={ref} className="h-full" />;
} }
@@ -286,6 +286,7 @@ const CSS = `
.cal-mini-grid button:hover{background:rgb(var(--app-hover))} .cal-mini-grid button:hover{background:rgb(var(--app-hover))}
.cal-mini-grid button.out{color:rgb(var(--app-faint))} .cal-mini-grid button.out{color:rgb(var(--app-faint))}
.cal-mini-grid button.today{background:#3b82f6;color:#fff} .cal-mini-grid button.today{background:#3b82f6;color:#fff}
.cal-mini-grid button.sel{background:rgb(var(--app-active));font-weight:600}
/* month */ /* month */
.cal-month{display:grid;grid-template-columns:repeat(7,1fr);grid-template-rows:auto;grid-auto-rows:minmax(0,1fr);height:100%} .cal-month{display:grid;grid-template-columns:repeat(7,1fr);grid-template-rows:auto;grid-auto-rows:minmax(0,1fr);height:100%}
.cal-month .dow{border-bottom:1px solid rgb(var(--app-border));border-right:1px solid rgb(var(--app-border));padding:4px 8px;font-size:13px;font-weight:600;text-transform:uppercase;color:rgb(var(--app-faint))} .cal-month .dow{border-bottom:1px solid rgb(var(--app-border));border-right:1px solid rgb(var(--app-border));padding:4px 8px;font-size:13px;font-weight:600;text-transform:uppercase;color:rgb(var(--app-faint))}
@@ -443,6 +444,9 @@ export default function mount(el, ctx, props) {
// any change here propagates to both places. // any change here propagates to both places.
const embed = !!(props && props.embed === "panel"); const embed = !!(props && props.embed === "panel");
const newEventGuests = (props && props.newEventGuests) || null; const newEventGuests = (props && props.newEventGuests) || null;
// Embed: the host can drop the "Open calendar in new tab" footer (Mail on a
// phone, where a new tab is no use).
const newTabLink = !(props && props.newTabLink === false);
const state = { const state = {
embed, embed,
@@ -466,6 +470,7 @@ export default function mount(el, ctx, props) {
panelMenuOpen: false, // embed: "..." view-options menu open panelMenuOpen: false, // embed: "..." view-options menu open
panelMini: "month", // embed mini calendar: month | week panelMini: "month", // embed mini calendar: month | week
panelView: "agenda", // embed agenda: day | 3days | agenda panelView: "agenda", // embed agenda: day | 3days | agenda
scrollToAnchor: false, // embed: bring the anchor day to the top of the agenda once
me: "", me: "",
quota: "", quota: "",
jitsi: "", // custom Jitsi server (blank => https://meet.jit.si/) jitsi: "", // custom Jitsi server (blank => https://meet.jit.si/)
@@ -1047,6 +1052,13 @@ export default function mount(el, ctx, props) {
${state.confirm ? confirmHtml() : ""} ${state.confirm ? confirmHtml() : ""}
</div>`; </div>`;
wire(); wire();
if (state.scrollToAnchor) {
state.scrollToAnchor = false;
const a = state.anchor;
const row = el.querySelector(`[data-agday="${a.getFullYear()}-${pad2(a.getMonth() + 1)}-${pad2(a.getDate())}"]`);
const body = el.querySelector(".cal-embed-body");
if (row && body) body.scrollTop += row.getBoundingClientRect().top - body.getBoundingClientRect().top;
}
if (state.eventPopover) positionPopover(); if (state.eventPopover) positionPopover();
if (state.search && !state.eventPopover && !state.composing && !state.confirm) focusSearch(); if (state.search && !state.eventPopover && !state.composing && !state.confirm) focusSearch();
} }
@@ -1087,6 +1099,7 @@ export default function mount(el, ctx, props) {
} }
function panelFoot() { function panelFoot() {
if (!newTabLink) return "";
return `<div class="cal-panelfoot"><button data-act="open-newtab">Open calendar in new tab <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M14 4h6v6M20 4l-9 9M18 14v4a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4"/></svg></button></div>`; return `<div class="cal-panelfoot"><button data-act="open-newtab">Open calendar in new tab <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M14 4h6v6M20 4l-9 9M18 14v4a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4"/></svg></button></div>`;
} }
@@ -1186,7 +1199,10 @@ export default function mount(el, ctx, props) {
let cells = ""; let cells = "";
for (let i = 0; i < cellCount; i++) { for (let i = 0; i < cellCount; i++) {
const d = addDays(gs, i); const d = addDays(gs, i);
const cls = [!week && d.getMonth() !== m.getMonth() ? "out" : "", sameDay(d, new Date()) ? "today" : ""].join(" "); const today = sameDay(d, new Date());
// Embed: the agenda has no other sign of which day was picked.
const sel = state.embed && !today && sameDay(d, state.anchor);
const cls = [!week && d.getMonth() !== m.getMonth() ? "out" : "", today ? "today" : "", sel ? "sel" : ""].join(" ");
cells += `<button class="${cls}" data-mini="${iso(d)}">${d.getDate()}</button>`; cells += `<button class="${cls}" data-mini="${iso(d)}">${d.getDate()}</button>`;
} }
return `<div class="cal-mini"> return `<div class="cal-mini">
@@ -1466,7 +1482,7 @@ export default function mount(el, ctx, props) {
const d = addDays(start, i); const d = addDays(start, i);
if (!short && d.getMonth() !== start.getMonth()) break; if (!short && d.getMonth() !== start.getMonth()) break;
const dayEvs = evs.filter((ev) => sameDay(ev._s, d)).sort((a, b) => a._s - b._s); const dayEvs = evs.filter((ev) => sameDay(ev._s, d)).sort((a, b) => a._s - b._s);
html += `<div class="day"><div class="dt">${DAYNAMES[(d.getDay() + 6) % 7].toUpperCase()}<b>${d.getDate()}</b></div><div style="flex:1">${ html += `<div class="day" data-agday="${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}"><div class="dt">${DAYNAMES[(d.getDay() + 6) % 7].toUpperCase()}<b>${d.getDate()}</b></div><div style="flex:1">${
dayEvs.length ? dayEvs.map((ev) => `<div class="cal-ev" data-ev="${ev.id}" data-start="${esc(ev.start)}" style="border-color:${esc(ev.color)}">${ev.all_day ? "" : hhmm(ev._s) + " "}${esc(ev.summary || "(no title)")}</div>`).join("") : `<span class="none">No events</span>` dayEvs.length ? dayEvs.map((ev) => `<div class="cal-ev" data-ev="${ev.id}" data-start="${esc(ev.start)}" style="border-color:${esc(ev.color)}">${ev.all_day ? "" : hhmm(ev._s) + " "}${esc(ev.summary || "(no title)")}</div>`).join("") : `<span class="none">No events</span>`
}</div></div>`; }</div></div>`;
} }
@@ -1530,7 +1546,7 @@ export default function mount(el, ctx, props) {
act("panel", () => { state.rightOpen = !state.rightOpen; render(); }); act("panel", () => { state.rightOpen = !state.rightOpen; render(); });
act("miniprev", () => { state.mini = new Date(state.mini.getFullYear(), state.mini.getMonth() - 1, 1); render(); }); act("miniprev", () => { state.mini = new Date(state.mini.getFullYear(), state.mini.getMonth() - 1, 1); render(); });
act("mininext", () => { state.mini = new Date(state.mini.getFullYear(), state.mini.getMonth() + 1, 1); render(); }); act("mininext", () => { state.mini = new Date(state.mini.getFullYear(), state.mini.getMonth() + 1, 1); render(); });
act("mini-today", () => { state.anchor = startOfDay(new Date()); state.mini = startOfMonth(state.anchor); reload(); }); act("mini-today", () => { state.anchor = startOfDay(new Date()); state.mini = startOfMonth(state.anchor); state.scrollToAnchor = state.embed; reload(); });
// --- left panel: collapse, theme, resize (like Mail) --- // --- left panel: collapse, theme, resize (like Mail) ---
act("left-toggle", () => { act("left-toggle", () => {
state.leftCollapsed = !state.leftCollapsed; state.leftCollapsed = !state.leftCollapsed;
@@ -1591,7 +1607,16 @@ export default function mount(el, ctx, props) {
}); });
on("[data-view]", "click", (e) => { state.view = e.currentTarget.getAttribute("data-view"); reload(); }); on("[data-view]", "click", (e) => { state.view = e.currentTarget.getAttribute("data-view"); reload(); });
on("[data-mini]", "click", (e) => { state.anchor = startOfDay(new Date(e.currentTarget.getAttribute("data-mini"))); if (state.view === "month") state.view = "day"; reload(); }); on("[data-mini]", "click", (e) => {
state.anchor = startOfDay(new Date(e.currentTarget.getAttribute("data-mini")));
if (state.embed) {
// The panel's agenda already shows the whole month, so without this a
// picked day changed nothing on screen.
state.mini = startOfMonth(state.anchor);
state.scrollToAnchor = true;
} else if (state.view === "month") state.view = "day";
reload();
});
on("[data-cal]", "change", (e) => { on("[data-cal]", "change", (e) => {
const id = e.currentTarget.getAttribute("data-cal"); const id = e.currentTarget.getAttribute("data-cal");
if (e.currentTarget.checked) state.hidden.delete(id); else state.hidden.add(id); if (e.currentTarget.checked) state.hidden.delete(id); else state.hidden.add(id);