import PanelStack from "../panel_stack.js";
/* SkuaDate — trigger + keyboard-navigable calendar (W3C APG date grid), with an
optional time bar (hour / minute / AM·PM, or 24h military) above the grid.
A hidden ISO <input> is the source of truth — `yyyy-mm-dd` for a date, or
`yyyy-mm-ddThh:mm` when data-time is set. The day grid supports:
Arrow L/R ±1 day Arrow U/D ±1 week
Home/End week start/end
PageUp/Dn ±1 month Enter/Space pick the focused day
Escape close
min/max bound the selectable range; out-of-range days are disabled.
*/
const MONTHS = [
"January", "February", "March", "April", "May", "June",
"July", "August", "September", "October", "November", "December",
];
const DOW = ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"];
const pad = (n) => String(n).padStart(2, "0");
export default {
mounted() {
this.trigger = this.el.querySelector("[data-sk-trigger]");
this.valEl = this.trigger.querySelector("[data-sk-value]");
this.hidden = this.el.querySelector("[data-sk-date-value]");
this.time = this.el.hasAttribute("data-time");
this.fmt24 = this.el.dataset.timeFormat === "24";
this.today = this.parse(this.el.dataset.today) || stripTime(new Date());
this.min = this.parse(this.el.dataset.min);
this.max = this.parse(this.el.dataset.max);
this.selected = this.parse(this.el.dataset.value || (this.hidden && this.hidden.value));
this.hours = this.selected ? this.selected.getHours() : 9;
this.minutes = this.selected ? this.selected.getMinutes() : 0;
this.focusDate = this.selected || this.today;
this.view = new Date(this.focusDate.getFullYear(), this.focusDate.getMonth(), 1);
this.buildPanel();
this.onTrigger = (e) => {
e.stopPropagation();
PanelStack.toggle(this.trigger, this.panel, {
returnFocus: this.trigger,
focusPanel: false,
onClose: () => this.trigger.setAttribute("aria-expanded", "false"),
});
if (PanelStack.isOpen(this.panel)) {
this.trigger.setAttribute("aria-expanded", "true");
requestAnimationFrame(() => this.focusActive());
}
};
this.trigger.addEventListener("click", this.onTrigger);
this.renderLabel();
},
updated() {
const v = this.parse(this.el.dataset.value || (this.hidden && this.hidden.value));
this.selected = v;
if (v) {
this.hours = v.getHours();
this.minutes = v.getMinutes();
}
this.renderLabel();
if (PanelStack.isOpen(this.panel)) this.renderGrid();
},
destroyed() {
if (this.panel) this.panel.remove();
this.trigger.removeEventListener("click", this.onTrigger);
},
/* ---- date/time helpers ------------------------------------------------- */
parse(s) {
if (!s) return null;
const m = /^(\d{4})-(\d{2})-(\d{2})(?:[T ](\d{2}):(\d{2}))?/.exec(s);
return m
? new Date(+m[1], +m[2] - 1, +m[3], m[4] ? +m[4] : 0, m[5] ? +m[5] : 0)
: null;
},
dateIso(d) {
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
},
iso(d) {
if (!d) return "";
const base = this.dateIso(d);
return this.time ? `${base}T${pad(this.hours)}:${pad(this.minutes)}` : base;
},
timeLabel() {
if (this.fmt24) return `${pad(this.hours)}:${pad(this.minutes)}`;
const h = this.hours % 12 || 12;
return `${h}:${pad(this.minutes)} ${this.hours < 12 ? "AM" : "PM"}`;
},
fmt(d) {
if (!d) return "";
let s = `${MONTHS[d.getMonth()].slice(0, 3)} ${d.getDate()}, ${d.getFullYear()}`;
if (this.time) s += ` · ${this.timeLabel()}`;
return s;
},
same(a, b) {
return (
a && b &&
a.getFullYear() === b.getFullYear() &&
a.getMonth() === b.getMonth() &&
a.getDate() === b.getDate()
);
},
disabled(d) {
return (this.min && d < stripTime(this.min)) || (this.max && d > this.max);
},
/* ---- panel ------------------------------------------------------------- */
buildPanel() {
const p = document.createElement("div");
p.className = "sk-panel sk-panel--pad sk-anim";
p.setAttribute("popover", "manual");
p.setAttribute("role", "dialog");
p.setAttribute("aria-label", this.time ? "Choose date and time" : "Choose date");
p.dataset.state = "closed";
p.dataset.matchWidth = "no";
p.addEventListener("click", (e) => e.stopPropagation());
const ampm = this.fmt24
? ""
: `<div class="sk-time-ampm" role="group" aria-label="AM or PM">
<button type="button" class="sk-time-meridiem" data-ampm="AM">AM</button>
<button type="button" class="sk-time-meridiem" data-ampm="PM">PM</button>
</div>`;
const timeBar = this.time
? `<div class="sk-time-bar">
<input class="sk-time-field sk-focusable" data-hh inputmode="numeric" maxlength="2" aria-label="Hour" />
<span class="sk-time-colon">:</span>
<input class="sk-time-field sk-focusable" data-mm inputmode="numeric" maxlength="2" aria-label="Minute" />
${ampm}
</div>`
: "";
p.innerHTML = `
<div class="sk-cal">
${timeBar}
<div class="sk-cal-head">
<button type="button" class="sk-cal-nav" data-prev aria-label="Previous month"><svg class="sk-glyph" viewBox="0 0 24 24" width="16" height="16" aria-hidden="true"><path d="m15 18-6-6 6-6"/></svg></button>
<span class="sk-cal-title" data-title aria-live="polite"></span>
<button type="button" class="sk-cal-nav" data-next aria-label="Next month"><svg class="sk-glyph" viewBox="0 0 24 24" width="16" height="16" aria-hidden="true"><path d="m9 18 6-6-6-6"/></svg></button>
</div>
<div class="sk-cal-grid">${DOW.map((d) => `<div class="sk-cal-dow" aria-hidden="true">${d}</div>`).join("")}</div>
<div class="sk-cal-grid" data-days role="grid"></div>
<div class="sk-cal-foot">
<button type="button" class="sk-link-btn" data-today>${this.time ? "Now" : "Today"}</button>
<button type="button" class="sk-link-btn" data-clear>Clear</button>
</div>
</div>`;
this.titleEl = p.querySelector("[data-title]");
this.daysEl = p.querySelector("[data-days]");
p.querySelector("[data-prev]").addEventListener("click", (e) => {
e.stopPropagation();
this.shiftMonth(-1);
});
p.querySelector("[data-next]").addEventListener("click", (e) => {
e.stopPropagation();
this.shiftMonth(1);
});
p.querySelector("[data-today]").addEventListener("click", (e) => {
e.stopPropagation();
if (this.time) {
const now = new Date();
this.hours = now.getHours();
this.minutes = now.getMinutes();
this.syncTimeFields();
}
this.commit(new Date(this.today));
});
p.querySelector("[data-clear]").addEventListener("click", (e) => {
e.stopPropagation();
this.commit(null);
});
this.daysEl.addEventListener("keydown", (e) => this.handleKey(e));
if (this.time) this.wireTime(p);
document.body.appendChild(p);
this.panel = p;
this.renderGrid();
},
wireTime(p) {
this.hhEl = p.querySelector("[data-hh]");
this.mmEl = p.querySelector("[data-mm]");
this.syncTimeFields();
this.hhEl.addEventListener("input", () => {
let h = parseInt(this.hhEl.value.replace(/\D/g, ""), 10);
if (isNaN(h)) return;
if (this.fmt24) this.hours = Math.max(0, Math.min(23, h));
else {
h = Math.max(1, Math.min(12, h));
const pm = this.hours >= 12;
this.hours = (h % 12) + (pm ? 12 : 0);
}
this.applyTime();
});
this.mmEl.addEventListener("input", () => {
const m = parseInt(this.mmEl.value.replace(/\D/g, ""), 10);
if (isNaN(m)) return;
this.minutes = Math.max(0, Math.min(59, m));
this.applyTime();
});
this.mmEl.addEventListener("blur", () => this.syncTimeFields());
this.hhEl.addEventListener("blur", () => this.syncTimeFields());
p.querySelectorAll("[data-ampm]").forEach((b) =>
b.addEventListener("click", (e) => {
e.stopPropagation();
const pm = b.dataset.ampm === "PM";
this.hours = (this.hours % 12) + (pm ? 12 : 0);
this.syncTimeFields();
this.applyTime();
})
);
},
syncTimeFields() {
if (!this.hhEl) return;
this.hhEl.value = this.fmt24 ? pad(this.hours) : String(this.hours % 12 || 12);
this.mmEl.value = pad(this.minutes);
if (!this.fmt24) {
const pm = this.hours >= 12;
this.panel
?.querySelectorAll("[data-ampm]")
.forEach((b) => b.classList.toggle("is-active", (b.dataset.ampm === "PM") === pm));
}
},
// time changed: update the value in place (keep the panel open)
applyTime() {
if (!this.fmt24) this.syncMeridiem();
const base = this.selected || new Date(this.today);
this.selected = new Date(base.getFullYear(), base.getMonth(), base.getDate(), this.hours, this.minutes);
this.writeHidden();
this.renderLabel();
},
syncMeridiem() {
const pm = this.hours >= 12;
this.panel
?.querySelectorAll("[data-ampm]")
.forEach((b) => b.classList.toggle("is-active", (b.dataset.ampm === "PM") === pm));
},
shiftMonth(delta) {
this.view.setMonth(this.view.getMonth() + delta);
this.focusDate = new Date(this.view.getFullYear(), this.view.getMonth(), 1);
this.renderGrid();
this.focusActive();
},
renderGrid() {
this.titleEl.textContent = `${MONTHS[this.view.getMonth()]} ${this.view.getFullYear()}`;
this.daysEl.innerHTML = "";
const first = new Date(this.view.getFullYear(), this.view.getMonth(), 1);
const start = new Date(first);
start.setDate(1 - first.getDay());
for (let i = 0; i < 42; i++) {
const d = new Date(start);
d.setDate(start.getDate() + i);
const cell = document.createElement("button");
cell.type = "button";
cell.className = "sk-day";
cell.textContent = d.getDate();
cell.setAttribute("role", "gridcell");
cell.dataset.date = this.dateIso(d);
cell.setAttribute("aria-label", `${d.getDate()} ${MONTHS[d.getMonth()]} ${d.getFullYear()}`);
if (d.getMonth() !== this.view.getMonth()) cell.classList.add("is-outside");
if (this.same(d, this.today)) cell.classList.add("is-today");
if (this.same(d, this.selected)) {
cell.classList.add("is-selected");
cell.setAttribute("aria-selected", "true");
}
if (this.disabled(d)) {
cell.classList.add("is-disabled");
cell.setAttribute("aria-disabled", "true");
cell.tabIndex = -1;
} else {
cell.tabIndex = this.same(d, this.focusDate) ? 0 : -1;
cell.addEventListener("click", (e) => {
e.stopPropagation();
this.commit(new Date(d));
});
}
this.daysEl.appendChild(cell);
}
if (!this.daysEl.querySelector('[tabindex="0"]')) {
const firstEnabled = this.daysEl.querySelector(".sk-day:not(.is-disabled)");
if (firstEnabled) firstEnabled.tabIndex = 0;
}
if (PanelStack.isOpen(this.panel)) PanelStack.reposition();
},
cellFor(date) {
return this.daysEl.querySelector(`[data-date="${this.dateIso(date)}"]`);
},
focusActive() {
const cell = this.cellFor(this.focusDate) || this.daysEl.querySelector('[tabindex="0"]');
if (cell) cell.focus();
},
moveFocus(next) {
if (next.getMonth() !== this.view.getMonth() || next.getFullYear() !== this.view.getFullYear()) {
this.view = new Date(next.getFullYear(), next.getMonth(), 1);
this.focusDate = next;
this.renderGrid();
} else {
this.focusDate = next;
this.daysEl
.querySelectorAll(".sk-day")
.forEach((c) => (c.tabIndex = c.dataset.date === this.dateIso(next) ? 0 : -1));
}
this.focusActive();
},
handleKey(e) {
const d = new Date(this.focusDate);
let handled = true;
switch (e.key) {
case "ArrowLeft": d.setDate(d.getDate() - 1); break;
case "ArrowRight": d.setDate(d.getDate() + 1); break;
case "ArrowUp": d.setDate(d.getDate() - 7); break;
case "ArrowDown": d.setDate(d.getDate() + 7); break;
case "Home": d.setDate(d.getDate() - d.getDay()); break;
case "End": d.setDate(d.getDate() + (6 - d.getDay())); break;
case "PageUp": d.setMonth(d.getMonth() - 1); break;
case "PageDown": d.setMonth(d.getMonth() + 1); break;
case "Enter":
case " ":
if (!this.disabled(this.focusDate)) this.commit(new Date(this.focusDate));
return e.preventDefault();
case "Escape":
e.stopPropagation();
PanelStack.closePanel(this.panel);
return;
default:
handled = false;
}
if (handled) {
e.preventDefault();
this.moveFocus(d);
}
},
writeHidden() {
if (!this.hidden) return;
this.hidden.value = this.iso(this.selected);
this.hidden.dispatchEvent(new Event("input", { bubbles: true }));
this.hidden.dispatchEvent(new Event("change", { bubbles: true }));
},
// picking a day commits the full value and closes (date mode) or keeps the
// panel open (datetime mode, so the time can still be adjusted).
commit(d) {
this.selected = d
? new Date(d.getFullYear(), d.getMonth(), d.getDate(), this.hours, this.minutes)
: null;
if (d) {
this.view = new Date(d.getFullYear(), d.getMonth(), 1);
this.focusDate = this.selected;
}
this.writeHidden();
this.renderLabel();
this.renderGrid();
if (!this.time) PanelStack.closePanel(this.panel);
},
renderLabel() {
if (this.selected) {
this.valEl.textContent = this.fmt(this.selected);
this.trigger.classList.add("has-value");
} else {
this.valEl.innerHTML = `<span class="sk-placeholder">${
this.el.dataset.placeholder || "Pick a date…"
}</span>`;
this.trigger.classList.remove("has-value");
}
},
};
function stripTime(d) {
return new Date(d.getFullYear(), d.getMonth(), d.getDate());
}