Skip to main content

assets/js/skua/hooks/date.js

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());
}