173 lines
6.0 KiB
JavaScript
173 lines
6.0 KiB
JavaScript
// Shared month-grid calendar. Both pages render the exact same widget.
|
|
//
|
|
// new Calendar(container, {
|
|
// canClick(dateStr) -> bool which days are interactive
|
|
// isPicked(dateStr) -> bool draw the highlighter ring
|
|
// isOffered(dateStr) -> bool soft marker background (poll page)
|
|
// months() -> Map "YYYY-MM"->n render month pills instead of arrows;
|
|
// only these months are reachable (poll page)
|
|
// decorate(dateStr, cell) add badges/chips
|
|
// onToggle(dateStr) click handler
|
|
// })
|
|
|
|
const RING_SVG =
|
|
'<svg class="ring" viewBox="0 0 100 100" aria-hidden="true">' +
|
|
'<path d="M50 9 C77 7 93 22 92 48 C91 76 74 92 48 91 C21 90 8 74 9 49 C10 24 27 11 53 10" ' +
|
|
'fill="none" stroke="currentColor" stroke-width="7.5" stroke-linecap="round" opacity="0.95"/></svg>';
|
|
|
|
const DOW = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
|
|
|
|
function pad(n) { return String(n).padStart(2, "0"); }
|
|
|
|
function dateStr(y, m, d) { return `${y}-${pad(m + 1)}-${pad(d)}`; }
|
|
|
|
function todayStr() {
|
|
const t = new Date();
|
|
return dateStr(t.getFullYear(), t.getMonth(), t.getDate());
|
|
}
|
|
|
|
function formatDate(ds) {
|
|
const [y, m, d] = ds.split("-").map(Number);
|
|
return new Date(y, m - 1, d).toLocaleDateString(undefined, {
|
|
weekday: "short", day: "numeric", month: "short", year: "numeric",
|
|
});
|
|
}
|
|
|
|
class Calendar {
|
|
constructor(el, opts) {
|
|
this.el = el;
|
|
this.opts = opts;
|
|
const now = new Date();
|
|
this.year = now.getFullYear();
|
|
this.month = now.getMonth();
|
|
this.render();
|
|
}
|
|
|
|
goTo(ds) {
|
|
const [y, m] = ds.split("-").map(Number);
|
|
this.year = y;
|
|
this.month = m - 1;
|
|
this.render();
|
|
}
|
|
|
|
shift(delta) {
|
|
const d = new Date(this.year, this.month + delta, 1);
|
|
this.year = d.getFullYear();
|
|
this.month = d.getMonth();
|
|
this.render();
|
|
}
|
|
|
|
render() {
|
|
const o = this.opts;
|
|
const first = new Date(this.year, this.month, 1);
|
|
const daysInMonth = new Date(this.year, this.month + 1, 0).getDate();
|
|
const lead = (first.getDay() + 6) % 7; // Monday-first
|
|
const today = todayStr();
|
|
|
|
this.el.innerHTML = "";
|
|
this.el.classList.add("cal");
|
|
|
|
const head = document.createElement("div");
|
|
head.className = "cal-head";
|
|
const title = document.createElement("div");
|
|
title.className = "cal-title";
|
|
title.textContent = first.toLocaleDateString(undefined, { month: "long", year: "numeric" });
|
|
const months = o.months ? o.months() : null;
|
|
const nav = document.createElement("div");
|
|
if (months && months.size > 0) {
|
|
// Fixed set of months containing options: pills, no free navigation.
|
|
nav.className = "cal-months";
|
|
const keys = [...months.keys()].sort();
|
|
const multiYear = new Set(keys.map((k) => k.slice(0, 4))).size > 1;
|
|
for (const key of keys) {
|
|
const [y, m] = key.split("-").map(Number);
|
|
const d = new Date(y, m - 1, 1);
|
|
const n = months.get(key);
|
|
const b = document.createElement("button");
|
|
b.type = "button";
|
|
b.className = "pill";
|
|
b.append(
|
|
d.toLocaleDateString(undefined, multiYear ? { month: "short", year: "2-digit" } : { month: "short" }),
|
|
);
|
|
const count = document.createElement("span");
|
|
count.className = "n";
|
|
count.textContent = n;
|
|
b.appendChild(count);
|
|
b.setAttribute("aria-label",
|
|
d.toLocaleDateString(undefined, { month: "long", year: "numeric" }) +
|
|
`, ${n} ${n === 1 ? "date" : "dates"} offered`);
|
|
if (y === this.year && m - 1 === this.month) {
|
|
b.classList.add("active");
|
|
b.setAttribute("aria-current", "true");
|
|
}
|
|
b.addEventListener("click", () => this.goTo(key + "-01"));
|
|
nav.appendChild(b);
|
|
}
|
|
} else {
|
|
nav.className = "cal-nav";
|
|
for (const [sym, delta, label] of [["\u2190", -1, "Previous month"], ["\u2192", 1, "Next month"]]) {
|
|
const b = document.createElement("button");
|
|
b.type = "button";
|
|
b.textContent = sym;
|
|
b.setAttribute("aria-label", label);
|
|
b.addEventListener("click", () => this.shift(delta));
|
|
nav.appendChild(b);
|
|
}
|
|
}
|
|
head.append(title, nav);
|
|
this.el.appendChild(head);
|
|
|
|
const grid = document.createElement("div");
|
|
grid.className = "cal-grid";
|
|
for (const d of DOW) {
|
|
const h = document.createElement("div");
|
|
h.className = "dow";
|
|
h.textContent = d;
|
|
grid.appendChild(h);
|
|
}
|
|
for (let i = 0; i < lead; i++) {
|
|
const blank = document.createElement("div");
|
|
blank.className = "day blank";
|
|
grid.appendChild(blank);
|
|
}
|
|
for (let d = 1; d <= daysInMonth; d++) {
|
|
const ds = dateStr(this.year, this.month, d);
|
|
const clickable = o.canClick && o.canClick(ds);
|
|
const cell = document.createElement(clickable ? "button" : "div");
|
|
if (clickable) cell.type = "button";
|
|
cell.className = "day";
|
|
cell.dataset.date = ds;
|
|
|
|
const num = document.createElement("span");
|
|
num.className = "num";
|
|
num.textContent = d;
|
|
cell.appendChild(num);
|
|
|
|
if (ds === today) cell.classList.add("today");
|
|
if (ds < today) cell.classList.add("past");
|
|
if (o.isOffered && o.isOffered(ds)) cell.classList.add("offered");
|
|
|
|
if (clickable) {
|
|
cell.classList.add("clickable");
|
|
cell.insertAdjacentHTML("beforeend", RING_SVG);
|
|
const picked = o.isPicked && o.isPicked(ds);
|
|
if (picked) cell.classList.add("picked");
|
|
cell.setAttribute("aria-pressed", picked ? "true" : "false");
|
|
cell.setAttribute("aria-label", formatDate(ds));
|
|
cell.addEventListener("click", () => {
|
|
o.onToggle(ds);
|
|
// Repaint only this cell for snappy feel.
|
|
const nowPicked = o.isPicked(ds);
|
|
cell.classList.toggle("picked", nowPicked);
|
|
cell.setAttribute("aria-pressed", nowPicked ? "true" : "false");
|
|
if (o.afterToggle) o.afterToggle();
|
|
});
|
|
}
|
|
|
|
if (o.decorate) o.decorate(ds, cell);
|
|
grid.appendChild(cell);
|
|
}
|
|
this.el.appendChild(grid);
|
|
}
|
|
}
|