Calendar/Contacts/Mail: pill resize handles, day hover, locale-safe date picker

- 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 <input type=date> (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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U72FZiF1W1pb8XxyWBTREA
This commit is contained in:
2026-08-04 19:23:44 +02:00
co-authored by Claude Opus 4.8
parent 0a478a6b26
commit 0f686bbd5c
4 changed files with 151 additions and 5 deletions
+2
View File
@@ -27,6 +27,8 @@ export function Resizer({ onDelta }: { onDelta: (deltaX: number) => void }) {
aria-orientation="vertical"
>
<div className="absolute inset-y-0 left-1/2 w-px -translate-x-1/2 bg-transparent group-hover:bg-app-accent" />
{/* Small pill grab handle in the middle for easier grabbing. */}
<div className="absolute left-1/2 top-1/2 h-7 w-[5px] -translate-x-1/2 -translate-y-1/2 rounded-full border border-app-border bg-app-elevated group-hover:border-app-accent group-hover:bg-app-accent" />
</div>
);
}
@@ -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 <input type=date> 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 = `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="m4 4l16 16m-3.5-3.244C15.147 17.485 13.618 18 12 18c-3.53 0-6.634-2.452-8.413-4.221c-.47-.467-.705-.7-.854-1.159c-.107-.327-.107-.913 0-1.24c.15-.459.385-.693.855-1.16c.897-.892 2.13-1.956 3.584-2.793M19.5 14.634c.333-.293.638-.582.912-.854l.003-.003c.468-.466.703-.7.852-1.156c.107-.327.107-.914 0-1.241c-.15-.458-.384-.692-.854-1.159C18.633 8.452 15.531 6 12 6q-.507 0-1 .064m2.323 7.436a2 2 0 0 1-2.762-2.889"/></svg>`;
@@ -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
<input type=date> 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 += "<span></span>";
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 += `<button type="button" data-day="${d}" class="${isSel ? "sel" : ""}${isToday ? " today" : ""}">${d}</button>`;
}
pop.innerHTML = `
<div class="cal-datepop-head">
<button type="button" data-nav="-1">‹</button>
<span>${MONTHS[viewM - 1]} ${viewY}</span>
<button type="button" data-nav="1">›</button>
</div>
<div class="cal-datepop-dow">${DAYNAMES.map((d) => `<span>${d[0]}</span>`).join("")}</div>
<div class="cal-datepop-grid">${cells}</div>`;
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) {
</div>
<h4>When</h4>
<label class="row" style="margin-bottom:6px"><input type="checkbox" data-f="allday" style="width:auto"/> All day</label>
<div class="row"><input type="date" data-f="date" value="${c.date}"/><select data-f="time">${timeOptionsHtml(c.time)}</select></div>
<div class="row"><span class="cal-datewrap" data-datepick><input type="text" data-datedisplay autocomplete="off"/><input type="hidden" data-f="date" value="${c.date}"/></span><select data-f="time">${timeOptionsHtml(c.time)}</select></div>
<div style="margin-top:8px"><select data-f="duration">
<option value="15">15 minutes</option><option value="30">30 minutes</option>
<option value="60" selected>1 hour</option><option value="90">1.5 hours</option><option value="120">2 hours</option>
@@ -1122,7 +1264,7 @@ export default function mount(el, ctx, props) {
<div class="row" style="margin-top:8px">
<select data-f="endmode"><option value="forever">Repeats forever</option><option value="count">Ends after N</option><option value="until">Ends on date</option></select>
<input type="number" data-f="count" value="10" min="1" style="width:64px;display:none"/>
<input type="date" data-f="until" style="display:none"/>
<span class="cal-datewrap" data-datepick data-untilwrap style="display:none;width:150px"><input type="text" data-datedisplay autocomplete="off"/><input type="hidden" data-f="until"/></span>
</div>
</div>
<h4>Who</h4>
@@ -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;
@@ -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) */
Binary file not shown.

After

Width:  |  Height:  |  Size: 910 B