From 0f686bbd5ccc9ed3143a72229fe479370d948747 Mon Sep 17 00:00:00 2001 From: Lukas Remis Date: Tue, 4 Aug 2026 19:23:44 +0200 Subject: [PATCH] Calendar/Contacts/Mail: pill resize handles, day hover, locale-safe date picker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Panel resize handles: replace the tall bar with a small centered pill/capsule (elevated fill + border, accent on hover) across Mail, Calendar and Contacts. - Calendar month/week/3-day views: brighten a day block on hover. - Calendar month grid: shade the current month's days theme-aware — a subtle dark overlay in light mode (they were only distinguished in dark mode before), white overlay in dark mode. - Calendar event form: replace the native (whose order follows the browser locale) with a custom field driven by the 24h/12h preference — an editable text box (typed dd/mm/yyyy for 24h) backed by a hidden ISO input, plus a popup month picker. Applies to the start date and the recurrence "Ends on date". Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01U72FZiF1W1pb8XxyWBTREA --- frontend/src/components/Resizer.tsx | 2 + .../webmail_plugin_calendar/ui/page.js | 152 +++++++++++++++++- .../webmail_plugin_contacts/ui/page.js | 2 + screenshots/handle.png | Bin 0 -> 910 bytes 4 files changed, 151 insertions(+), 5 deletions(-) create mode 100644 screenshots/handle.png diff --git a/frontend/src/components/Resizer.tsx b/frontend/src/components/Resizer.tsx index 9e9eb60..505cf82 100644 --- a/frontend/src/components/Resizer.tsx +++ b/frontend/src/components/Resizer.tsx @@ -27,6 +27,8 @@ export function Resizer({ onDelta }: { onDelta: (deltaX: number) => void }) { aria-orientation="vertical" >
+ {/* Small pill grab handle in the middle for easier grabbing. */} +
); } diff --git a/plugins/webmail_plugin_calendar/webmail_plugin_calendar/ui/page.js b/plugins/webmail_plugin_calendar/webmail_plugin_calendar/ui/page.js index 137fbf1..310b27a 100644 --- a/plugins/webmail_plugin_calendar/webmail_plugin_calendar/ui/page.js +++ b/plugins/webmail_plugin_calendar/webmail_plugin_calendar/ui/page.js @@ -19,6 +19,13 @@ const esc = (s) => String(s == null ? "" : s).replace(/[&<>]/g, (c) => ({ "&": " let HOUR12 = false; // Date order follows the time preference: 24h → en-GB (day/month/year), 12h → en-US. const dateLocale = () => (HOUR12 ? "en-US" : "en-GB"); +// Format a native date input's ISO value (yyyy-mm-dd) for display in the preferred +// order — the native text is fixed to the browser locale, so the +// form overlays this label instead. +const fmtDateInput = (iso) => { + const [y, m, d] = String(iso || "").split("-").map(Number); + return y && m && d ? new Date(y, m - 1, d).toLocaleDateString(dateLocale()) : ""; +}; const pad2 = (n) => String(n).padStart(2, "0"); // Icons copied from Mail (frontend/icons/*.svg) so the two apps match exactly. const HIDE_ICON = ``; @@ -214,6 +221,8 @@ const CSS = ` .cal-resizer{flex:0 0 auto;width:5px;margin:0 -2px;cursor:col-resize;position:relative;z-index:10} .cal-resizer::after{content:"";position:absolute;top:0;bottom:0;left:50%;width:1px;transform:translateX(-50%);background:transparent} .cal-resizer:hover::after{background:#3b82f6} +.cal-resizer::before{content:"";position:absolute;top:50%;left:50%;width:5px;height:28px;transform:translate(-50%,-50%);border-radius:999px;background:rgb(var(--app-elevated));border:1px solid rgb(var(--app-border));box-sizing:border-box;z-index:1} +.cal-resizer:hover::before{background:#3b82f6;border-color:#3b82f6} .cal-main{flex:1;min-width:0;display:flex;flex-direction:column} .cal-right{width:320px;flex:0 0 auto;border-left:1px solid rgb(var(--app-border));overflow:auto} /* search results panel */ @@ -286,8 +295,12 @@ const CSS = ` .cal-cell .mtag{color:rgb(var(--app-muted));font-weight:400;font-size:14px} .cal-cell.out .n{color:rgb(var(--app-faint))} .cal-cell.out .mtag{color:rgb(var(--app-faint))} -.cal-cell:not(.out){background:rgba(255,255,255,.03)} +.cal-cell:not(.out){background:rgba(0,0,0,.02)} +.dark .cal-cell:not(.out){background:rgba(255,255,255,.03)} .cal-cell.today{background:rgba(59,130,246,.06)} +.cal-cell:hover{background:rgba(0,0,0,.055)} +.dark .cal-cell:hover{background:rgba(255,255,255,.06)} +.cal-cell.today:hover{background:rgba(59,130,246,.11)} .cal-cell.today .num{background:#3b82f6;color:#fff} .cal-ev{font-size:14px;padding:1px 4px;border-radius:4px;margin-top:2px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;cursor:pointer;border-left:3px solid #3b82f6;background:rgb(var(--app-hover))} .cal-ev.srch-hl,.cal-tev.srch-hl{outline:2px solid #3b82f6;outline-offset:-1px;background:rgba(59,130,246,.28)} @@ -305,6 +318,9 @@ const CSS = ` .cal-cols{display:grid;position:relative} .cal-col{border-left:1px solid rgb(var(--app-border));position:relative} .cal-col.today{background:rgba(59,130,246,.06)} +.cal-col:hover{background:rgba(0,0,0,.045)} +.dark .cal-col:hover{background:rgba(255,255,255,.05)} +.cal-col.today:hover{background:rgba(59,130,246,.11)} .cal-col .line{height:${HOUR_PX}px;border-top:1px solid rgb(var(--app-border))} .cal-tev{position:absolute;left:2px;width:calc(100% - 4px);border-radius:5px;border:1px solid rgb(var(--app-elevated));border-left:3px solid #3b82f6;background:rgb(var(--app-hover));padding:2px 5px;font-size:14px;overflow:hidden;cursor:pointer;box-sizing:border-box} .cal-now{position:absolute;left:0;right:0;height:0;border-top:2px solid #ef4444;z-index:4} @@ -348,6 +364,22 @@ const CSS = ` .cal-sw.sel{border-color:rgb(var(--app-fg))} .cal-form input[type=text],.cal-form input[type=email],.cal-form textarea,.cal-form select,.cal-form input[type=date],.cal-form input[type=time],.cal-form input[type=number]{width:100%;padding:7px 9px;border:1px solid rgb(var(--app-border));border-radius:7px;background:rgb(var(--app-elevated));color:rgb(var(--app-fg));font:inherit;box-sizing:border-box} .cal-form .row{display:flex;gap:8px;align-items:center} +/* Custom date field: a formatted read-only text box + popup month picker. Native + renders its text in the browser locale's order, which we can't + control — this drives the display order entirely from the 24h/12h preference. */ +.cal-datewrap{position:relative;display:block;width:100%;min-width:0} +.cal-datewrap input[data-datedisplay]{cursor:text} +.cal-datepop{position:absolute;left:0;top:100%;margin-top:4px;z-index:60;background:rgb(var(--app-elevated));border:1px solid rgb(var(--app-border));border-radius:10px;box-shadow:0 12px 32px rgba(0,0,0,.4);padding:8px;width:236px} +.cal-datepop-head{display:flex;align-items:center;justify-content:space-between;margin-bottom:6px} +.cal-datepop-head span{font-size:13px;font-weight:600;color:rgb(var(--app-fg))} +.cal-datepop-head button{border:0;background:none;color:rgb(var(--app-fg));cursor:pointer;width:26px;height:26px;border-radius:6px;font-size:17px;line-height:1} +.cal-datepop-head button:hover{background:rgb(var(--app-hover))} +.cal-datepop-dow,.cal-datepop-grid{display:grid;grid-template-columns:repeat(7,1fr)} +.cal-datepop-dow span{text-align:center;font-size:11px;color:rgb(var(--app-faint));padding:2px 0} +.cal-datepop-grid button{border:0;background:none;color:rgb(var(--app-fg));cursor:pointer;height:28px;border-radius:50%;font:inherit;font-size:13px} +.cal-datepop-grid button:hover{background:rgb(var(--app-hover))} +.cal-datepop-grid button.today{outline:1px solid rgb(var(--app-border))} +.cal-datepop-grid button.sel{background:#3b82f6;color:#fff} .cal-form .muted{color:rgb(var(--app-muted));font-size:13px} .cal-formbtns{display:flex;gap:8px;margin-top:16px} .cal-guest{display:inline-flex;align-items:center;gap:4px;background:rgb(var(--app-hover));border-radius:10px;padding:2px 8px;font-size:12px;margin:2px 4px 2px 0} @@ -472,6 +504,115 @@ export default function mount(el, ctx, props) { } function pad(n) { return String(n).padStart(2, "0"); } + // Wire every custom date field in the form: an editable text box showing the date in + // the preferred order (typing is parsed in that order), backed by a hidden ISO input, + // plus a popup month picker. + function initDatePickers() { + el.querySelectorAll("[data-datepick]").forEach(setupDatePicker); + } + + // Parse a hand-typed date honouring the preferred order (24h → d/m/y, 12h → m/d/y). + function parseTypedDate(str) { + const parts = String(str).split(/[^0-9]+/).filter(Boolean).map(Number); + if (parts.length < 3) return null; + let d, m, y; + if (HOUR12) [m, d, y] = parts; else [d, m, y] = parts; + if (y < 100) y += 2000; + if (!(y && m >= 1 && m <= 12 && d >= 1 && d <= 31)) return null; + const dt = new Date(y, m - 1, d); + if (dt.getFullYear() !== y || dt.getMonth() !== m - 1 || dt.getDate() !== d) return null; + return `${y}-${pad2(m)}-${pad2(d)}`; + } + + function setupDatePicker(wrap) { + if (wrap.dataset.wired) return; // idempotent across re-renders + wrap.dataset.wired = "1"; + const hidden = wrap.querySelector("input[type=hidden]"); + const display = wrap.querySelector("[data-datedisplay]"); + display.setAttribute("placeholder", HOUR12 ? "mm/dd/yyyy" : "dd/mm/yyyy"); + const parse = (iso) => { + const [y, m, d] = String(iso || "").split("-").map(Number); + return y && m && d ? { y, m, d } : null; + }; + const refresh = () => { display.value = fmtDateInput(hidden.value); }; + // Accept the typed text into the hidden ISO value, then normalise the display. + const commit = () => { + const isoTyped = parseTypedDate(display.value); + if (isoTyped) hidden.value = isoTyped; + refresh(); + }; + let pop = null; + let viewY = 0; + let viewM = 0; // 1-12 + + const onDoc = (e) => { if (pop && !wrap.contains(e.target)) { commit(); close(); } }; + function close() { + if (pop) { pop.remove(); pop = null; } + document.removeEventListener("mousedown", onDoc); + } + function open() { + if (pop) return; + const cur = parseTypedDate(display.value) ? parse(parseTypedDate(display.value)) + : parse(hidden.value) || (() => { + const n = new Date(); + return { y: n.getFullYear(), m: n.getMonth() + 1, d: n.getDate() }; + })(); + viewY = cur.y; viewM = cur.m; + pop = document.createElement("div"); + pop.className = "cal-datepop"; + // Keep focus in the text box so clicking the picker never blurs mid-type. + pop.addEventListener("mousedown", (e) => e.preventDefault()); + wrap.appendChild(pop); + renderPop(); + setTimeout(() => document.addEventListener("mousedown", onDoc), 0); + } + function renderPop() { + const sel = parse(hidden.value); + const today = new Date(); + const startDow = (new Date(viewY, viewM - 1, 1).getDay() + 6) % 7; // Monday-first + const daysIn = new Date(viewY, viewM, 0).getDate(); + let cells = ""; + for (let i = 0; i < startDow; i++) cells += ""; + for (let d = 1; d <= daysIn; d++) { + const isSel = sel && sel.y === viewY && sel.m === viewM && sel.d === d; + const isToday = today.getFullYear() === viewY && today.getMonth() + 1 === viewM && today.getDate() === d; + cells += ``; + } + pop.innerHTML = ` +
+ + ${MONTHS[viewM - 1]} ${viewY} + +
+
${DAYNAMES.map((d) => `${d[0]}`).join("")}
+
${cells}
`; + pop.querySelectorAll("[data-nav]").forEach((b) => b.addEventListener("click", () => { + viewM += Number(b.getAttribute("data-nav")); + if (viewM < 1) { viewM = 12; viewY--; } + if (viewM > 12) { viewM = 1; viewY++; } + renderPop(); + })); + pop.querySelectorAll("[data-day]").forEach((b) => b.addEventListener("click", () => { + hidden.value = `${viewY}-${pad2(viewM)}-${pad2(Number(b.getAttribute("data-day")))}`; + refresh(); + close(); + display.blur(); + })); + } + display.addEventListener("focus", open); + // Live-sync the picker's shown month as a valid date is typed. + display.addEventListener("input", () => { + const isoTyped = parseTypedDate(display.value); + if (isoTyped) { hidden.value = isoTyped; const p = parse(isoTyped); viewY = p.y; viewM = p.m; if (pop) renderPop(); } + }); + display.addEventListener("keydown", (e) => { + if (e.key === "Enter") { e.preventDefault(); commit(); close(); display.blur(); } + else if (e.key === "Escape") { refresh(); close(); display.blur(); } + }); + display.addEventListener("blur", () => { if (!pop) commit(); }); + refresh(); + } + function openForm(when, editEv) { const d = when || new Date(); // default to the current time state.eventPopover = null; @@ -487,6 +628,7 @@ export default function mount(el, ctx, props) { render(); if (editEv) prefillForm(editEv); else applyDefaultReminders(state.defaultRemTimed); // new events start with the user's defaults + initDatePickers(); const first = el.querySelector("[data-f=summary]"); if (first) first.focus(); } @@ -565,7 +707,7 @@ export default function mount(el, ctx, props) { } else if (r.until) { q("[data-f=endmode]").value = "until"; q("[data-f=until]").value = r.until; - q("[data-f=until]").style.display = ""; + q("[data-untilwrap]").style.display = ""; } } // Guests — expand the Who section and restore chips (no auto video-call). @@ -1100,7 +1242,7 @@ export default function mount(el, ctx, props) {

When

-
+
- +

Who

@@ -1481,7 +1623,7 @@ export default function mount(el, ctx, props) { on("[data-f=endmode]", "change", (e) => { const v = e.currentTarget.value; el.querySelector("[data-f=count]").style.display = v === "count" ? "" : "none"; - el.querySelector("[data-f=until]").style.display = v === "until" ? "" : "none"; + el.querySelector("[data-untilwrap]").style.display = v === "until" ? "" : "none"; }); on("[data-f=allday]", "change", (e) => { const off = e.currentTarget.checked; diff --git a/plugins/webmail_plugin_contacts/webmail_plugin_contacts/ui/page.js b/plugins/webmail_plugin_contacts/webmail_plugin_contacts/ui/page.js index d74a689..3c5a2a6 100644 --- a/plugins/webmail_plugin_contacts/webmail_plugin_contacts/ui/page.js +++ b/plugins/webmail_plugin_contacts/webmail_plugin_contacts/ui/page.js @@ -87,6 +87,8 @@ const CSS = ` .ct-resizer{flex:0 0 auto;width:5px;margin:0 -2px;cursor:col-resize;position:relative;z-index:10} .ct-resizer::after{content:"";position:absolute;top:0;bottom:0;left:50%;width:1px;transform:translateX(-50%);background:transparent} .ct-resizer:hover::after{background:#3b82f6} +.ct-resizer::before{content:"";position:absolute;top:50%;left:50%;width:5px;height:28px;transform:translate(-50%,-50%);border-radius:999px;background:rgb(var(--app-elevated));border:1px solid rgb(var(--app-border));box-sizing:border-box;z-index:1} +.ct-resizer:hover::before{background:#3b82f6;border-color:#3b82f6} .ct-bar{display:flex;align-items:center;gap:8px;height:49px;padding:0 14px 0 20px;border-bottom:1px solid rgb(var(--app-border));flex:0 0 auto} .ct-bar .sp{flex:1} /* Mail-style selection checkbox (bar "select all" + per-row on avatar-hover) */ diff --git a/screenshots/handle.png b/screenshots/handle.png new file mode 100644 index 0000000000000000000000000000000000000000..de92edf68df232058eab7bcebbc291353803a546 GIT binary patch literal 910 zcmeAS@N?(olHy`uVBq!ia0vp^zCgT{gAGVZohUj2q*#ibJVQ8upoSx*1IU*wag8Vm z&QB{TPb^AhNYBg9P1P+X4&F>H9zNc zcXwYqT(Q~nHRGP+F3g>5LcA(c9wrkq6fQa}Y8)y8cNbp0b#?pxf43Z8Gpd|4;IXuf zeDdLg!R@!c#|qDs?LK?>u-?)wa(7?PTFiTR!sW~Uk!!E*-1Tdo%FOe}d)zm#sn6SV z`z!l(k+s)mJ$~#Owz_oM>DSL+`|nTR%pbYFUhSp#+Mo5ea>{-%y;=G6lht7Z6N!ua zv-`sCMN}`Ha%I++Z+RJg-`-mZdpmFWKijtU;NIq$*Wz&o-W8s=nbJU;UY7iN^{>S0 z@7oDS=iV02HM@Q0`Qz2EKmVMud-v}zY|}~_w#CGyZ=8`X(?2i$&dHO7V%?Qyvwgih z%h|O-v3HX%ey!=&cm7N4>S9vNW-pyG>Gtj0ckbkT{;|Vb^XXaLk7A~CGpi>&`S;Im z`gHqGUte#Rk>LUQ`+S0}-uY+G=T*;`m14hm+2v**HUIOQO17I!=DRRyx_Z!L&-&9J r&(GS=@o!D}h9%Bhz7K|^@P+&4;~epG1|