All checks were successful
CI / lint-and-test (push) Successful in 14s
- Added functionality to lock the background scroll when the day detail panel is open, improving user interaction by preventing background movement. - Implemented logic to restore the scroll position when the panel is closed, ensuring a seamless user experience. - Updated CSS to support the new behavior, enhancing the visual consistency of the day detail panel. - Added early return checks in navigation button event listeners to prevent actions while the day detail panel is open, improving performance and usability.
315 lines
10 KiB
JavaScript
315 lines
10 KiB
JavaScript
/**
|
||
* Day detail panel: popover (desktop) or bottom sheet (mobile) on calendar cell tap.
|
||
*/
|
||
|
||
import { calendarEl, state } from "./dom.js";
|
||
import { t } from "./i18n.js";
|
||
import { escapeHtml } from "./utils.js";
|
||
import { localDateString, dateKeyToDDMM } from "./dateUtils.js";
|
||
import { getDutyMarkerRows } from "./hints.js";
|
||
|
||
const BOTTOM_SHEET_BREAKPOINT_PX = 640;
|
||
const POPOVER_MARGIN = 12;
|
||
|
||
/** @type {HTMLElement|null} */
|
||
let panelEl = null;
|
||
/** @type {HTMLElement|null} */
|
||
let overlayEl = null;
|
||
/** @type {((e: MouseEvent) => void)|null} */
|
||
let popoverCloseHandler = null;
|
||
/** Scroll position saved when sheet opens (for restore on close). */
|
||
let sheetScrollY = 0;
|
||
|
||
/**
|
||
* Parse JSON from data attribute (values are stored with " for safety).
|
||
* @param {string} raw
|
||
* @returns {object[]|string[]}
|
||
*/
|
||
function parseDataAttr(raw) {
|
||
if (!raw) return [];
|
||
try {
|
||
const s = raw.replace(/"/g, '"');
|
||
const parsed = JSON.parse(s);
|
||
return Array.isArray(parsed) ? parsed : [];
|
||
} catch (e) {
|
||
return [];
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Build HTML content for the day detail panel.
|
||
* @param {string} dateKey - YYYY-MM-DD
|
||
* @param {object[]} duties - Array of { event_type, full_name, start_at?, end_at? }
|
||
* @param {string[]} eventSummaries - Calendar event summary strings
|
||
* @returns {string}
|
||
*/
|
||
export function buildDayDetailContent(dateKey, duties, eventSummaries) {
|
||
const lang = state.lang;
|
||
const todayKey = localDateString(new Date());
|
||
const ddmm = dateKeyToDDMM(dateKey);
|
||
const title =
|
||
dateKey === todayKey
|
||
? t(lang, "duty.today") + ", " + ddmm
|
||
: ddmm;
|
||
|
||
const dutyList = (duties || []).filter((d) => d.event_type === "duty");
|
||
const unavailableList = (duties || []).filter((d) => d.event_type === "unavailable");
|
||
const vacationList = (duties || []).filter((d) => d.event_type === "vacation");
|
||
const summaries = eventSummaries || [];
|
||
|
||
const fromLabel = t(lang, "hint.from");
|
||
const toLabel = t(lang, "hint.to");
|
||
const nbsp = "\u00a0";
|
||
|
||
let html =
|
||
'<h2 id="dayDetailTitle" class="day-detail-title">' + escapeHtml(title) + "</h2>";
|
||
html += '<div class="day-detail-sections">';
|
||
|
||
if (dutyList.length > 0) {
|
||
const hasTimes = dutyList.some(
|
||
(it) => (it.start_at || it.end_at)
|
||
);
|
||
const rows = hasTimes
|
||
? getDutyMarkerRows(dutyList, dateKey, nbsp, fromLabel, toLabel)
|
||
: dutyList.map((it) => ({ timePrefix: "", fullName: it.full_name || "" }));
|
||
|
||
html +=
|
||
'<section class="day-detail-section day-detail-section--duty">' +
|
||
'<h3 class="day-detail-section-title">' +
|
||
escapeHtml(t(lang, "event_type.duty")) +
|
||
"</h3><ul class=" +
|
||
'"day-detail-list">';
|
||
rows.forEach((r) => {
|
||
const timeHtml = r.timePrefix ? escapeHtml(r.timePrefix) + " — " : "";
|
||
html +=
|
||
"<li>" +
|
||
(timeHtml ? '<span class="day-detail-time">' + timeHtml + "</span>" : "") +
|
||
escapeHtml(r.fullName) +
|
||
"</li>";
|
||
});
|
||
html += "</ul></section>";
|
||
}
|
||
|
||
if (unavailableList.length > 0) {
|
||
html +=
|
||
'<section class="day-detail-section day-detail-section--unavailable">' +
|
||
'<h3 class="day-detail-section-title">' +
|
||
escapeHtml(t(lang, "event_type.unavailable")) +
|
||
"</h3><ul class=\"day-detail-list\">";
|
||
unavailableList.forEach((d) => {
|
||
html += "<li>" + escapeHtml(d.full_name || "") + "</li>";
|
||
});
|
||
html += "</ul></section>";
|
||
}
|
||
|
||
if (vacationList.length > 0) {
|
||
html +=
|
||
'<section class="day-detail-section day-detail-section--vacation">' +
|
||
'<h3 class="day-detail-section-title">' +
|
||
escapeHtml(t(lang, "event_type.vacation")) +
|
||
"</h3><ul class=\"day-detail-list\">";
|
||
vacationList.forEach((d) => {
|
||
html += "<li>" + escapeHtml(d.full_name || "") + "</li>";
|
||
});
|
||
html += "</ul></section>";
|
||
}
|
||
|
||
if (summaries.length > 0) {
|
||
html +=
|
||
'<section class="day-detail-section day-detail-section--events">' +
|
||
'<h3 class="day-detail-section-title">' +
|
||
escapeHtml(t(lang, "hint.events")) +
|
||
"</h3><ul class=\"day-detail-list\">";
|
||
summaries.forEach((s) => {
|
||
html += "<li>" + escapeHtml(String(s)) + "</li>";
|
||
});
|
||
html += "</ul></section>";
|
||
}
|
||
|
||
html += "</div>";
|
||
return html;
|
||
}
|
||
|
||
/**
|
||
* Position panel as popover near cell rect; keep inside viewport.
|
||
* @param {HTMLElement} panel
|
||
* @param {DOMRect} cellRect
|
||
*/
|
||
function positionPopover(panel, cellRect) {
|
||
const vw = document.documentElement.clientWidth;
|
||
const vh = document.documentElement.clientHeight;
|
||
const margin = POPOVER_MARGIN;
|
||
panel.classList.remove("day-detail-panel--below");
|
||
panel.style.left = "";
|
||
panel.style.top = "";
|
||
panel.style.right = "";
|
||
panel.style.bottom = "";
|
||
panel.hidden = false;
|
||
requestAnimationFrame(() => {
|
||
const panelRect = panel.getBoundingClientRect();
|
||
let left = cellRect.left + cellRect.width / 2 - panelRect.width / 2;
|
||
let top = cellRect.bottom + 8;
|
||
if (top + panelRect.height > vh - margin) {
|
||
top = cellRect.top - panelRect.height - 8;
|
||
panel.classList.add("day-detail-panel--below");
|
||
} else {
|
||
panel.classList.remove("day-detail-panel--below");
|
||
}
|
||
if (left < margin) left = margin;
|
||
if (left + panelRect.width > vw - margin) left = vw - panelRect.width - margin;
|
||
panel.style.left = left + "px";
|
||
panel.style.top = top + "px";
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Show panel as bottom sheet (fixed at bottom).
|
||
*/
|
||
function showAsBottomSheet() {
|
||
if (!panelEl || !overlayEl) return;
|
||
overlayEl.hidden = false;
|
||
panelEl.classList.add("day-detail-panel--sheet");
|
||
panelEl.style.left = "";
|
||
panelEl.style.top = "";
|
||
panelEl.style.right = "";
|
||
panelEl.style.bottom = "0";
|
||
panelEl.hidden = false;
|
||
|
||
sheetScrollY = window.scrollY;
|
||
document.body.classList.add("day-detail-sheet-open");
|
||
document.body.style.top = "-" + sheetScrollY + "px";
|
||
|
||
const closeBtn = panelEl.querySelector(".day-detail-close");
|
||
if (closeBtn) closeBtn.focus();
|
||
}
|
||
|
||
/**
|
||
* Show panel as popover at cell position.
|
||
* @param {DOMRect} cellRect
|
||
*/
|
||
function showAsPopover(cellRect) {
|
||
if (!panelEl || !overlayEl) return;
|
||
overlayEl.hidden = true;
|
||
panelEl.classList.remove("day-detail-panel--sheet");
|
||
positionPopover(panelEl, cellRect);
|
||
if (popoverCloseHandler) document.removeEventListener("click", popoverCloseHandler);
|
||
popoverCloseHandler = (e) => {
|
||
const target = e.target instanceof Node ? e.target : null;
|
||
if (!target || !panelEl) return;
|
||
if (panelEl.contains(target)) return;
|
||
if (target instanceof HTMLElement && calendarEl && calendarEl.contains(target) && target.closest(".day")) return;
|
||
hideDayDetail();
|
||
};
|
||
setTimeout(() => document.addEventListener("click", popoverCloseHandler), 0);
|
||
}
|
||
|
||
/**
|
||
* Hide panel and overlay.
|
||
*/
|
||
export function hideDayDetail() {
|
||
if (popoverCloseHandler) {
|
||
document.removeEventListener("click", popoverCloseHandler);
|
||
popoverCloseHandler = null;
|
||
}
|
||
if (document.body.classList.contains("day-detail-sheet-open")) {
|
||
document.body.classList.remove("day-detail-sheet-open");
|
||
document.body.style.top = "";
|
||
window.scrollTo(0, sheetScrollY);
|
||
}
|
||
if (panelEl) panelEl.hidden = true;
|
||
if (overlayEl) overlayEl.hidden = true;
|
||
}
|
||
|
||
/**
|
||
* Open day detail for the given cell (reads data from data-* attributes).
|
||
* @param {HTMLElement} cell - .day element
|
||
*/
|
||
function openDayDetail(cell) {
|
||
const dateKey = cell.getAttribute("data-date");
|
||
if (!dateKey) return;
|
||
|
||
const dutiesRaw = cell.getAttribute("data-day-duties");
|
||
const eventsRaw = cell.getAttribute("data-day-events");
|
||
const duties = /** @type {object[]} */ (parseDataAttr(dutiesRaw || ""));
|
||
const eventSummaries = /** @type {string[]} */ (parseDataAttr(eventsRaw || ""));
|
||
|
||
if (duties.length === 0 && eventSummaries.length === 0) return;
|
||
|
||
ensurePanelInDom();
|
||
if (!panelEl) return;
|
||
|
||
const contentHtml = buildDayDetailContent(dateKey, duties, eventSummaries);
|
||
const body = panelEl.querySelector(".day-detail-body");
|
||
if (body) body.innerHTML = contentHtml;
|
||
|
||
const isNarrow = window.matchMedia("(max-width: " + BOTTOM_SHEET_BREAKPOINT_PX + "px)").matches;
|
||
if (isNarrow) {
|
||
showAsBottomSheet();
|
||
} else {
|
||
const rect = cell.getBoundingClientRect();
|
||
showAsPopover(rect);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Ensure #dayDetailPanel and overlay exist in DOM.
|
||
*/
|
||
function ensurePanelInDom() {
|
||
if (panelEl) return;
|
||
overlayEl = document.getElementById("dayDetailOverlay");
|
||
panelEl = document.getElementById("dayDetailPanel");
|
||
if (!overlayEl) {
|
||
overlayEl = document.createElement("div");
|
||
overlayEl.id = "dayDetailOverlay";
|
||
overlayEl.className = "day-detail-overlay";
|
||
overlayEl.setAttribute("aria-hidden", "true");
|
||
overlayEl.hidden = true;
|
||
document.body.appendChild(overlayEl);
|
||
}
|
||
if (!panelEl) {
|
||
panelEl = document.createElement("div");
|
||
panelEl.id = "dayDetailPanel";
|
||
panelEl.className = "day-detail-panel";
|
||
panelEl.setAttribute("role", "dialog");
|
||
panelEl.setAttribute("aria-modal", "true");
|
||
panelEl.setAttribute("aria-labelledby", "dayDetailTitle");
|
||
panelEl.hidden = true;
|
||
|
||
const closeLabel = t(state.lang, "day_detail.close");
|
||
panelEl.innerHTML =
|
||
'<button type="button" class="day-detail-close" aria-label="' +
|
||
escapeHtml(closeLabel) +
|
||
'">×</button><div class="day-detail-body"></div>';
|
||
|
||
const closeBtn = panelEl.querySelector(".day-detail-close");
|
||
if (closeBtn) {
|
||
closeBtn.addEventListener("click", hideDayDetail);
|
||
}
|
||
panelEl.addEventListener("keydown", (e) => {
|
||
if (e.key === "Escape") hideDayDetail();
|
||
});
|
||
overlayEl.addEventListener("click", hideDayDetail);
|
||
document.body.appendChild(panelEl);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Bind delegated click/keydown on calendar for .day cells.
|
||
*/
|
||
export function initDayDetail() {
|
||
if (!calendarEl) return;
|
||
calendarEl.addEventListener("click", (e) => {
|
||
const cell = /** @type {HTMLElement} */ (e.target instanceof HTMLElement ? e.target.closest(".day") : null);
|
||
if (!cell) return;
|
||
e.preventDefault();
|
||
openDayDetail(cell);
|
||
});
|
||
calendarEl.addEventListener("keydown", (e) => {
|
||
if (e.key !== "Enter" && e.key !== " ") return;
|
||
const cell = /** @type {HTMLElement} */ (e.target instanceof HTMLElement ? e.target.closest(".day") : null);
|
||
if (!cell) return;
|
||
e.preventDefault();
|
||
openDayDetail(cell);
|
||
});
|
||
}
|