Files
caiwuzongzhang/web/datepicker.js
T
6eccbe9765 HEL-373: 主题化 combobox 覆盖夜间下拉全状态,日期面板支持年月直选
原生 select 弹层无法可靠套黑金 token,改为可访问 listbox;日期标题增加年份/月份下拉,越月不提交非法日。

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-09-02 11:07:16 +08:00

368 lines
12 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* 锚定在日期输入旁的页内日历。不引入第三方库;滚动/缩放后跟随触发器。 */
(function (global) {
var WEEK = ["日", "一", "二", "三", "四", "五", "六"];
var panel = null;
var grid = null;
var titleEl = null;
var yearSelect = null;
var monthSelect = null;
var activeInput = null;
var viewYear = 0;
var viewMonth = 0;
var bound = false;
var YEAR_PAD = 80;
var YEAR_AHEAD = 20;
function pad(n) {
return n < 10 ? "0" + n : String(n);
}
function toISO(year, month, day) {
return year + "-" + pad(month + 1) + "-" + pad(day);
}
function parseISO(value) {
var match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(String(value || "").trim());
if (!match) return null;
var year = Number(match[1]);
var month = Number(match[2]) - 1;
var day = Number(match[3]);
var date = new Date(year, month, day);
if (date.getFullYear() !== year || date.getMonth() !== month || date.getDate() !== day) {
return null;
}
return date;
}
function todayISO() {
var now = new Date();
return toISO(now.getFullYear(), now.getMonth(), now.getDate());
}
function ensurePanel() {
if (panel) return;
panel = document.createElement("div");
panel.className = "ds-datepicker";
panel.id = "ds-datepicker";
panel.hidden = true;
panel.setAttribute("role", "dialog");
panel.setAttribute("aria-label", "选择日期");
panel.innerHTML =
'<div class="ds-dp-head">' +
'<button type="button" class="ds-dp-nav" data-dp-nav="-1" aria-label="上一月"></button>' +
'<div class="ds-dp-title">' +
'<select class="select ds-dp-ym" data-dp-year aria-label="年份"></select>' +
'<select class="select ds-dp-ym" data-dp-month aria-label="月份"></select>' +
"</div>" +
'<button type="button" class="ds-dp-nav" data-dp-nav="1" aria-label="下一月"></button>' +
"</div>" +
'<div class="ds-dp-week">' + WEEK.map(function (d) { return "<span>" + d + "</span>"; }).join("") + "</div>" +
'<div class="ds-dp-grid" role="grid"></div>' +
'<div class="ds-dp-foot">' +
'<button type="button" data-dp-today>今天</button>' +
'<button type="button" data-dp-clear>清除</button>' +
"</div>";
document.body.appendChild(panel);
titleEl = panel.querySelector(".ds-dp-title");
yearSelect = panel.querySelector("[data-dp-year]");
monthSelect = panel.querySelector("[data-dp-month]");
grid = panel.querySelector(".ds-dp-grid");
yearSelect.addEventListener("change", function () {
var next = Number(yearSelect.value);
if (!next) return;
viewYear = next;
render();
});
monthSelect.addEventListener("change", function () {
var next = Number(monthSelect.value);
if (Number.isNaN(next)) return;
viewMonth = next;
render();
});
panel.addEventListener("mousedown", function (event) {
if (event.target.closest(".ds-combo, select, input, textarea")) return;
event.preventDefault();
});
panel.addEventListener("click", function (event) {
if (event.target.closest(".ds-combo, .ds-listbox, select")) return;
var nav = event.target.closest("[data-dp-nav]");
if (nav) {
shiftMonth(Number(nav.getAttribute("data-dp-nav")));
return;
}
if (event.target.closest("[data-dp-today]")) {
commit(todayISO());
return;
}
if (event.target.closest("[data-dp-clear]")) {
commit("");
return;
}
var dayBtn = event.target.closest("[data-iso]");
if (dayBtn && !dayBtn.disabled) commit(dayBtn.getAttribute("data-iso"));
});
}
function enhance(input) {
if (!input || input.dataset.dsDate === "1") return;
input.dataset.dsDate = "1";
if (input.type === "date") {
var value = input.value;
try {
input.type = "text";
} catch (err) {
/* keep native type if the engine refuses */
}
if (value) input.value = value;
}
input.setAttribute("inputmode", "numeric");
input.setAttribute("autocomplete", "off");
input.setAttribute("spellcheck", "false");
input.setAttribute("placeholder", input.getAttribute("placeholder") || "YYYY-MM-DD");
if (!input.getAttribute("pattern")) input.setAttribute("pattern", "\\d{4}-\\d{2}-\\d{2}");
if (!input.classList.contains("num-input")) input.classList.add("num-input");
input.setAttribute("aria-haspopup", "dialog");
}
function scan() {
document.querySelectorAll('input[type="date"]').forEach(enhance);
}
function boundYears() {
var now = new Date().getFullYear();
var minY = now - YEAR_PAD;
var maxY = now + YEAR_AHEAD;
var hasMin = false;
var hasMax = false;
if (activeInput && activeInput.min) {
var minDate = parseISO(activeInput.min);
if (minDate) {
minY = minDate.getFullYear();
hasMin = true;
}
}
if (activeInput && activeInput.max) {
var maxDate = parseISO(activeInput.max);
if (maxDate) {
maxY = maxDate.getFullYear();
hasMax = true;
}
}
if (!hasMin && viewYear < minY) minY = viewYear;
if (!hasMax && viewYear > maxY) maxY = viewYear;
if (hasMin && viewYear < minY) minY = viewYear;
if (hasMax && viewYear > maxY) maxY = viewYear;
return { minY: minY, maxY: maxY };
}
function lastDayOfMonth(year, month) {
return new Date(year, month + 1, 0).getDate();
}
function monthHasValidDay(year, month) {
var min = activeInput && activeInput.min ? activeInput.min : "";
var max = activeInput && activeInput.max ? activeInput.max : "";
var start = toISO(year, month, 1);
var end = toISO(year, month, lastDayOfMonth(year, month));
if (min && end < min) return false;
if (max && start > max) return false;
return true;
}
function syncYearMonthSelects() {
if (!yearSelect || !monthSelect) return;
var years = boundYears();
var yearHtml = "";
for (var year = years.minY; year <= years.maxY; year += 1) {
yearHtml += '<option value="' + year + '"' + (year === viewYear ? " selected" : "") + ">" + year + "年</option>";
}
yearSelect.innerHTML = yearHtml;
yearSelect.value = String(viewYear);
var monthHtml = "";
for (var month = 0; month < 12; month += 1) {
var disabled = !monthHasValidDay(viewYear, month);
monthHtml +=
'<option value="' + month + '"' +
(month === viewMonth ? " selected" : "") +
(disabled ? " disabled" : "") +
">" + pad(month + 1) + "月</option>";
}
monthSelect.innerHTML = monthHtml;
monthSelect.value = String(viewMonth);
if (global.JinniuSelect && typeof global.JinniuSelect.scan === "function") {
global.JinniuSelect.scan(panel);
}
}
function render() {
if (!grid || !titleEl) return;
syncYearMonthSelects();
var first = new Date(viewYear, viewMonth, 1);
var start = first.getDay();
var selected = activeInput ? parseISO(activeInput.value) : null;
var selectedISO = selected ? toISO(selected.getFullYear(), selected.getMonth(), selected.getDate()) : "";
var today = todayISO();
var min = activeInput && activeInput.min ? activeInput.min : "";
var max = activeInput && activeInput.max ? activeInput.max : "";
var html = "";
var cursor = new Date(viewYear, viewMonth, 1 - start);
for (var i = 0; i < 42; i += 1) {
var iso = toISO(cursor.getFullYear(), cursor.getMonth(), cursor.getDate());
var other = cursor.getMonth() !== viewMonth;
var disabled = (min && iso < min) || (max && iso > max);
var cls = "ds-dp-day";
if (other) cls += " is-other";
if (iso === today) cls += " is-today";
if (iso === selectedISO) cls += " is-selected";
html +=
'<button type="button" class="' + cls + '" data-iso="' + iso + '"' +
(disabled ? " disabled" : "") +
' aria-label="' + iso + '"' +
(iso === selectedISO ? ' aria-current="date"' : "") +
">" + cursor.getDate() + "</button>";
cursor.setDate(cursor.getDate() + 1);
}
grid.innerHTML = html;
position();
}
function viewportBox() {
var vv = global.visualViewport;
if (vv) {
return {
left: vv.offsetLeft,
top: vv.offsetTop,
width: vv.width,
height: vv.height,
};
}
return { left: 0, top: 0, width: global.innerWidth, height: global.innerHeight };
}
function position() {
if (!panel || panel.hidden || !activeInput) return;
var rect = activeInput.getBoundingClientRect();
var view = viewportBox();
var gap = 6;
var width = panel.offsetWidth || 280;
var height = panel.offsetHeight || 320;
var left = rect.left;
var top = rect.bottom + gap;
if (top + height > view.top + view.height - 8 && rect.top - gap - height >= view.top + 8) {
top = rect.top - gap - height;
}
if (left + width > view.left + view.width - 8) {
left = Math.max(view.left + 8, rect.right - width);
}
if (left < view.left + 8) left = view.left + 8;
if (top < view.top + 8) top = view.top + 8;
panel.style.left = Math.round(left) + "px";
panel.style.top = Math.round(top) + "px";
}
function openPicker(input) {
if (!input || input.disabled) return;
enhance(input);
ensurePanel();
activeInput = input;
var parsed = parseISO(input.value) || new Date();
viewYear = parsed.getFullYear();
viewMonth = parsed.getMonth();
panel.hidden = false;
render();
input.setAttribute("aria-expanded", "true");
}
function closePicker() {
if (!panel || panel.hidden) return;
if (global.JinniuSelect && typeof global.JinniuSelect.close === "function") {
global.JinniuSelect.close();
}
panel.hidden = true;
if (activeInput) activeInput.setAttribute("aria-expanded", "false");
activeInput = null;
}
function shiftMonth(delta) {
viewMonth += delta;
while (viewMonth < 0) {
viewMonth += 12;
viewYear -= 1;
}
while (viewMonth > 11) {
viewMonth -= 12;
viewYear += 1;
}
render();
}
function commit(iso) {
if (!activeInput) return;
var input = activeInput;
input.value = iso;
input.dispatchEvent(new Event("input", { bubbles: true }));
input.dispatchEvent(new Event("change", { bubbles: true }));
closePicker();
input.focus({ preventScroll: true });
}
function onPointer(event) {
var input = event.target.closest && event.target.closest('input[type="date"], input[data-ds-date="1"]');
if (input) {
if (event.type === "mousedown" && event.button === 0) event.preventDefault();
openPicker(input);
return;
}
if (
panel &&
!panel.hidden &&
!panel.contains(event.target) &&
!(event.target.closest && event.target.closest(".ds-listbox"))
) {
closePicker();
}
}
function onKey(event) {
if (event.key === "Escape") {
closePicker();
return;
}
var input = event.target.closest && event.target.closest('input[data-ds-date="1"]');
if (!input) return;
if (event.key === "ArrowDown" || event.key === "Enter" || event.key === " ") {
event.preventDefault();
openPicker(input);
}
}
function bind() {
if (bound) return;
bound = true;
document.addEventListener("mousedown", onPointer, true);
document.addEventListener("focusin", function (event) {
var input = event.target.closest && event.target.closest('input[type="date"]');
if (input) openPicker(input);
});
document.addEventListener("keydown", onKey);
document.addEventListener("scroll", position, true);
global.addEventListener("resize", position);
if (global.visualViewport) {
global.visualViewport.addEventListener("resize", position);
global.visualViewport.addEventListener("scroll", position);
}
if (typeof MutationObserver === "function") {
new MutationObserver(scan).observe(document.documentElement, { childList: true, subtree: true });
}
scan();
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", bind);
} else {
bind();
}
global.JinniuDatePicker = { open: openPicker, close: closePicker, scan: scan };
})(window);