292 lines
8.7 KiB
JavaScript
292 lines
8.7 KiB
JavaScript
/**
|
||
* KamiDatePicker — 轻量日历弹窗,kami 暖纸风格。
|
||
*
|
||
* 用法:
|
||
* new KamiDatePicker(triggerEl, {
|
||
* value: '2025-06-09', // 初始日期
|
||
* maxDate: '2025-06-09', // 最晚可选
|
||
* minDate: '2024-01-01', // 最早可选
|
||
* markedDates: ['2025-06-08'],// 有数据的日期(小圆点)
|
||
* onChange: function(dateStr) {}, // 选择回调
|
||
* });
|
||
*/
|
||
;(function () {
|
||
'use strict';
|
||
|
||
var INSTANCES = [];
|
||
|
||
var WEEKDAYS = ['日', '一', '二', '三', '四', '五', '六'];
|
||
|
||
function pad(n) {
|
||
return n < 10 ? '0' + n : '' + n;
|
||
}
|
||
function isoDate(y, m, d) {
|
||
return y + '-' + pad(m + 1) + '-' + pad(d);
|
||
}
|
||
function parseDate(s) {
|
||
if (!s) return null;
|
||
var parts = s.split('-');
|
||
if (parts.length !== 3) return null;
|
||
return new Date(+parts[0], +parts[1] - 1, +parts[2]);
|
||
}
|
||
function sameDay(a, b) {
|
||
return a && b && a.getFullYear() === b.getFullYear() &&
|
||
a.getMonth() === b.getMonth() && a.getDate() === b.getDate();
|
||
}
|
||
|
||
function KamiDatePicker(triggerEl, opts) {
|
||
opts = opts || {};
|
||
this.trigger = triggerEl;
|
||
this.value = opts.value || null;
|
||
this.maxDate = opts.maxDate ? parseDate(opts.maxDate) : null;
|
||
this.minDate = opts.minDate ? parseDate(opts.minDate) : null;
|
||
this.markedSet = {};
|
||
if (opts.markedDates) {
|
||
for (var i = 0; i < opts.markedDates.length; i++) {
|
||
this.markedSet[opts.markedDates[i]] = true;
|
||
}
|
||
}
|
||
this.onChange = opts.onChange || function () {};
|
||
this.opened = false;
|
||
|
||
// view state
|
||
var initial = parseDate(this.value) || new Date();
|
||
this.viewYear = initial.getFullYear();
|
||
this.viewMonth = initial.getMonth();
|
||
|
||
this._build();
|
||
this._bind();
|
||
INSTANCES.push(this);
|
||
}
|
||
|
||
KamiDatePicker.prototype._build = function () {
|
||
// wrapper
|
||
this.wrapper = document.createElement('div');
|
||
this.wrapper.className = 'kami-date-picker-wrapper';
|
||
this.trigger.parentNode.insertBefore(this.wrapper, this.trigger);
|
||
this.wrapper.appendChild(this.trigger);
|
||
|
||
// popup
|
||
this.popup = document.createElement('div');
|
||
this.popup.className = 'kami-date-popup';
|
||
this.popup.innerHTML = this._renderHTML();
|
||
this.wrapper.appendChild(this.popup);
|
||
|
||
// make trigger look clickable
|
||
this.trigger.style.cursor = 'pointer';
|
||
};
|
||
|
||
KamiDatePicker.prototype._bind = function () {
|
||
var self = this;
|
||
|
||
// toggle on trigger click
|
||
this._triggerHandler = function (e) {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
self.toggle();
|
||
};
|
||
this.trigger.addEventListener('click', this._triggerHandler);
|
||
|
||
// popup internal clicks
|
||
this.popup.addEventListener('click', function (e) {
|
||
var target = e.target;
|
||
|
||
// prev month
|
||
if (target.closest('.kami-date-prev')) {
|
||
e.stopPropagation();
|
||
self.viewMonth--;
|
||
if (self.viewMonth < 0) { self.viewMonth = 11; self.viewYear--; }
|
||
self._update();
|
||
return;
|
||
}
|
||
// next month
|
||
if (target.closest('.kami-date-next')) {
|
||
e.stopPropagation();
|
||
self.viewMonth++;
|
||
if (self.viewMonth > 11) { self.viewMonth = 0; self.viewYear++; }
|
||
self._update();
|
||
return;
|
||
}
|
||
// today link
|
||
if (target.closest('.kami-date-today-link')) {
|
||
e.stopPropagation();
|
||
self.select(new Date());
|
||
return;
|
||
}
|
||
// day cell
|
||
var dayEl = target.closest('.kami-date-day');
|
||
if (dayEl && !dayEl.classList.contains('muted')) {
|
||
e.stopPropagation();
|
||
var y = +dayEl.dataset.year;
|
||
var m = +dayEl.dataset.month;
|
||
var d = +dayEl.dataset.day;
|
||
self.select(new Date(y, m, d));
|
||
}
|
||
});
|
||
};
|
||
|
||
KamiDatePicker.prototype._renderHTML = function () {
|
||
var y = this.viewYear;
|
||
var m = this.viewMonth;
|
||
var today = new Date();
|
||
var selected = parseDate(this.value);
|
||
|
||
var html = '';
|
||
|
||
// header
|
||
html += '<div class="kami-date-header">';
|
||
html += '<button type="button" class="kami-date-prev" title="上个月">‹</button>';
|
||
html += '<span class="kami-date-label">' + y + ' 年 ' + (m + 1) + ' 月</span>';
|
||
html += '<button type="button" class="kami-date-next" title="下个月">›</button>';
|
||
html += '</div>';
|
||
|
||
// weekdays
|
||
html += '<div class="kami-date-weekdays">';
|
||
for (var w = 0; w < 7; w++) {
|
||
html += '<span>' + WEEKDAYS[w] + '</span>';
|
||
}
|
||
html += '</div>';
|
||
|
||
// day grid
|
||
var firstDay = new Date(y, m, 1).getDay(); // 0=Sun
|
||
var daysInMonth = new Date(y, m + 1, 0).getDate();
|
||
var daysInPrevMonth = new Date(y, m, 0).getDate();
|
||
|
||
html += '<div class="kami-date-grid">';
|
||
|
||
// prev month trailing days
|
||
for (var p = firstDay - 1; p >= 0; p--) {
|
||
var pd = daysInPrevMonth - p;
|
||
var pm = m - 1;
|
||
var py = y;
|
||
if (pm < 0) { pm = 11; py--; }
|
||
html += '<div class="kami-date-day muted" data-year="' + py + '" data-month="' + pm + '" data-day="' + pd + '">' + pd + '</div>';
|
||
}
|
||
|
||
// current month days
|
||
for (var d = 1; d <= daysInMonth; d++) {
|
||
var date = new Date(y, m, d);
|
||
var iso = isoDate(y, m, d);
|
||
var cls = 'kami-date-day';
|
||
if (sameDay(date, today)) cls += ' today';
|
||
if (sameDay(date, selected)) cls += ' selected';
|
||
if (this.markedSet[iso]) cls += ' marked';
|
||
if (this.maxDate && date > this.maxDate) cls += ' disabled';
|
||
if (this.minDate && date < this.minDate) cls += ' disabled';
|
||
html += '<div class="' + cls + '" data-year="' + y + '" data-month="' + m + '" data-day="' + d + '">' + d + '</div>';
|
||
}
|
||
|
||
// next month leading days
|
||
var totalCells = firstDay + daysInMonth;
|
||
var remaining = (7 - totalCells % 7) % 7;
|
||
for (var n = 1; n <= remaining; n++) {
|
||
var nm = m + 1;
|
||
var ny = y;
|
||
if (nm > 11) { nm = 0; ny++; }
|
||
html += '<div class="kami-date-day muted" data-year="' + ny + '" data-month="' + nm + '" data-day="' + n + '">' + n + '</div>';
|
||
}
|
||
|
||
html += '</div>';
|
||
|
||
// footer — "回到今天"
|
||
html += '<div class="kami-date-footer">';
|
||
html += '<a href="javascript:void(0)" class="kami-date-today-link">回到今天</a>';
|
||
html += '</div>';
|
||
|
||
return html;
|
||
};
|
||
|
||
KamiDatePicker.prototype._update = function () {
|
||
this.popup.innerHTML = this._renderHTML();
|
||
};
|
||
|
||
KamiDatePicker.prototype.open = function () {
|
||
// close other instances
|
||
for (var i = 0; i < INSTANCES.length; i++) {
|
||
if (INSTANCES[i] !== this && INSTANCES[i].opened) {
|
||
INSTANCES[i].close();
|
||
}
|
||
}
|
||
|
||
// sync view to current value
|
||
var sel = parseDate(this.value);
|
||
if (sel) {
|
||
this.viewYear = sel.getFullYear();
|
||
this.viewMonth = sel.getMonth();
|
||
}
|
||
this._update();
|
||
this.popup.classList.add('open');
|
||
this.opened = true;
|
||
this._addGlobalListeners();
|
||
};
|
||
|
||
KamiDatePicker.prototype.close = function () {
|
||
this.popup.classList.remove('open');
|
||
this.opened = false;
|
||
this._removeGlobalListeners();
|
||
};
|
||
|
||
KamiDatePicker.prototype.toggle = function () {
|
||
if (this.opened) this.close();
|
||
else this.open();
|
||
};
|
||
|
||
KamiDatePicker.prototype.select = function (date) {
|
||
var iso = isoDate(date.getFullYear(), date.getMonth(), date.getDate());
|
||
this.value = iso;
|
||
this.close();
|
||
this.onChange(iso);
|
||
};
|
||
|
||
KamiDatePicker.prototype.setValue = function (dateStr) {
|
||
this.value = dateStr || null;
|
||
var d = parseDate(dateStr);
|
||
if (d) {
|
||
this.viewYear = d.getFullYear();
|
||
this.viewMonth = d.getMonth();
|
||
}
|
||
if (this.opened) this._update();
|
||
};
|
||
|
||
KamiDatePicker.prototype.getValue = function () {
|
||
return this.value;
|
||
};
|
||
|
||
KamiDatePicker.prototype.destroy = function () {
|
||
this.close();
|
||
this.trigger.removeEventListener('click', this._triggerHandler);
|
||
// move trigger back out of wrapper
|
||
this.wrapper.parentNode.insertBefore(this.trigger, this.wrapper);
|
||
this.wrapper.remove();
|
||
var idx = INSTANCES.indexOf(this);
|
||
if (idx > -1) INSTANCES.splice(idx, 1);
|
||
};
|
||
|
||
// ── Global listeners ──
|
||
|
||
KamiDatePicker.prototype._addGlobalListeners = function () {
|
||
var self = this;
|
||
if (!this._docClickHandler) {
|
||
this._docClickHandler = function (e) {
|
||
if (self.wrapper.contains(e.target)) return;
|
||
self.close();
|
||
};
|
||
}
|
||
if (!this._keyHandler) {
|
||
this._keyHandler = function (e) {
|
||
if (e.key === 'Escape') self.close();
|
||
};
|
||
}
|
||
document.addEventListener('click', this._docClickHandler);
|
||
document.addEventListener('keydown', this._keyHandler);
|
||
};
|
||
|
||
KamiDatePicker.prototype._removeGlobalListeners = function () {
|
||
if (this._docClickHandler) document.removeEventListener('click', this._docClickHandler);
|
||
if (this._keyHandler) document.removeEventListener('keydown', this._keyHandler);
|
||
};
|
||
|
||
// expose
|
||
window.KamiDatePicker = KamiDatePicker;
|
||
})();
|