327 lines
14 KiB
JavaScript
327 lines
14 KiB
JavaScript
/**
|
|
* Vernova - Lightweight Vanilla JS Jalali Date Picker
|
|
* No jQuery, no eval, no external dependencies. CSP-safe.
|
|
*/
|
|
(function () {
|
|
'use strict';
|
|
|
|
// ─── Jalali ↔ Gregorian Conversion ─────────────────────────
|
|
var JalaliConverter = {
|
|
gregorianToJalali: function (gy, gm, gd) {
|
|
var g_d_m = [0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334];
|
|
var gy2 = (gm > 2) ? (gy + 1) : gy;
|
|
var days = 355666 + (365 * gy) + Math.floor((gy2 + 3) / 4) - Math.floor((gy2 + 99) / 100) + Math.floor((gy2 + 399) / 400) + gd + g_d_m[gm - 1];
|
|
var jy = -1595 + (33 * Math.floor(days / 12053));
|
|
days %= 12053;
|
|
jy += 4 * Math.floor(days / 1461);
|
|
days %= 1461;
|
|
if (days > 365) {
|
|
jy += Math.floor((days - 1) / 365);
|
|
days = (days - 1) % 365;
|
|
}
|
|
var jm, jd;
|
|
if (days < 186) {
|
|
jm = 1 + Math.floor(days / 31);
|
|
jd = 1 + (days % 31);
|
|
} else {
|
|
jm = 7 + Math.floor((days - 186) / 30);
|
|
jd = 1 + ((days - 186) % 30);
|
|
}
|
|
return [jy, jm, jd];
|
|
},
|
|
jalaliToGregorian: function (jy, jm, jd) {
|
|
var jy2 = jy - 979;
|
|
var jm2 = jm - 1;
|
|
var jd2 = jd - 1;
|
|
var days = 365 * jy2 + Math.floor(jy2 / 33) * 8 + Math.floor((jy2 % 33 + 3) / 4) + 78 + jd2 + ((jm2 < 7) ? (jm2 * 31) : ((jm2 * 30) + 6));
|
|
var gy = 1600 + 400 * Math.floor(days / 146097);
|
|
days %= 146097;
|
|
if (days > 36524) {
|
|
gy += 100 * Math.floor(--days / 36524);
|
|
days %= 36524;
|
|
if (days >= 365) days++;
|
|
}
|
|
gy += 4 * Math.floor(days / 1461);
|
|
days %= 1461;
|
|
if (days > 365) {
|
|
gy += Math.floor((days - 1) / 365);
|
|
days = (days - 1) % 365;
|
|
}
|
|
var gd = days + 1;
|
|
var sal_a = [0, 31, ((gy % 4 === 0 && gy % 100 !== 0) || (gy % 400 === 0)) ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
|
|
var gm;
|
|
for (gm = 0; gm < 13 && gd > sal_a[gm]; gm++) gd -= sal_a[gm];
|
|
return [gy, gm, gd];
|
|
},
|
|
isJalaliLeap: function (jy) {
|
|
var breaks = [1, 5, 9, 13, 17, 22, 26, 30];
|
|
var r = jy % 33;
|
|
return breaks.indexOf(r) !== -1;
|
|
},
|
|
jalaliMonthDays: function (jy, jm) {
|
|
if (jm <= 6) return 31;
|
|
if (jm <= 11) return 30;
|
|
return this.isJalaliLeap(jy) ? 30 : 29;
|
|
},
|
|
gregorianMonthDays: function (gy, gm) {
|
|
var days = [0, 31, ((gy % 4 === 0 && gy % 100 !== 0) || (gy % 400 === 0)) ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
|
|
return days[gm];
|
|
}
|
|
};
|
|
|
|
// ─── Jalali Month/Day Names ────────────────────────────────
|
|
var jalaliMonths = ['فروردین', 'اردیبهشت', 'خرداد', 'تیر', 'مرداد', 'شهریور', 'مهر', 'آبان', 'آذر', 'دی', 'بهمن', 'اسفند'];
|
|
var jalaliWeekDaysShort = ['ش', 'ی', 'د', 'س', 'چ', 'پ', 'ج'];
|
|
var jalaliWeekDaysLong = ['شنبه', 'یکشنبه', 'دوشنبه', 'سهشنبه', 'چهارشنبه', 'پنجشنبه', 'جمعه'];
|
|
|
|
// ─── Pad helper ────────────────────────────────────────────
|
|
function pad(n) { return n < 10 ? '0' + n : '' + n; }
|
|
|
|
// ─── Date Picker Class ─────────────────────────────────────
|
|
function JalaliDatePicker(inputEl, hiddenEl) {
|
|
this.inputEl = inputEl;
|
|
this.hiddenEl = hiddenEl;
|
|
this.container = null;
|
|
this.isOpen = false;
|
|
this.currentYear = 1404;
|
|
this.currentMonth = 1;
|
|
this.selectedYear = null;
|
|
this.selectedMonth = null;
|
|
this.selectedDay = null;
|
|
|
|
this._init();
|
|
}
|
|
|
|
JalaliDatePicker.prototype = {
|
|
_init: function () {
|
|
// Parse initial value
|
|
var val = this.inputEl.value;
|
|
if (val && /^\d{4}\/\d{1,2}\/\d{1,2}$/.test(val)) {
|
|
var parts = val.split('/');
|
|
this.selectedYear = this.currentYear = parseInt(parts[0]);
|
|
this.selectedMonth = this.currentMonth = parseInt(parts[1]);
|
|
this.selectedDay = parseInt(parts[2]);
|
|
} else {
|
|
// Default to today
|
|
var now = new Date();
|
|
var today = JalaliConverter.gregorianToJalali(now.getFullYear(), now.getMonth() + 1, now.getDate());
|
|
this.currentYear = today[0];
|
|
this.currentMonth = today[1];
|
|
}
|
|
|
|
// Create container
|
|
this.container = document.createElement('div');
|
|
this.container.className = 'jalali-picker-dropdown';
|
|
this.container.style.display = 'none';
|
|
document.body.appendChild(this.container);
|
|
|
|
// Bind events
|
|
var self = this;
|
|
this.inputEl.addEventListener('focus', function (e) { self.open(); });
|
|
this.inputEl.addEventListener('click', function (e) { e.stopPropagation(); });
|
|
this.container.addEventListener('click', function (e) { e.stopPropagation(); });
|
|
|
|
// Close on outside click
|
|
document.addEventListener('click', function (e) {
|
|
if (self.isOpen && e.target !== self.inputEl && !self.container.contains(e.target)) {
|
|
self.close();
|
|
}
|
|
});
|
|
|
|
// Handle manual input
|
|
this.inputEl.addEventListener('blur', function () {
|
|
self._syncFromInput();
|
|
});
|
|
|
|
// Handle keyboard
|
|
this.inputEl.addEventListener('keydown', function (e) {
|
|
if (e.key === 'Escape') self.close();
|
|
if (e.key === 'Enter') { self._syncFromInput(); self.close(); }
|
|
});
|
|
},
|
|
|
|
_syncFromInput: function () {
|
|
var val = this.inputEl.value.trim();
|
|
if (/^\d{4}\/\d{1,2}\/\d{1,2}$/.test(val)) {
|
|
var parts = val.split('/');
|
|
var jy = parseInt(parts[0]), jm = parseInt(parts[1]), jd = parseInt(parts[2]);
|
|
var maxDay = JalaliConverter.jalaliMonthDays(jy, jm);
|
|
if (jd > maxDay) jd = maxDay;
|
|
this.selectedYear = jy;
|
|
this.selectedMonth = jm;
|
|
this.selectedDay = jd;
|
|
this.inputEl.value = pad(jy) + '/' + pad(jm) + '/' + pad(jd);
|
|
this._updateHidden();
|
|
} else if (val === '') {
|
|
this.selectedYear = null;
|
|
this.selectedMonth = null;
|
|
this.selectedDay = null;
|
|
if (this.hiddenEl) this.hiddenEl.value = '';
|
|
}
|
|
},
|
|
|
|
_updateHidden: function () {
|
|
if (this.selectedYear && this.selectedMonth && this.selectedDay) {
|
|
var g = JalaliConverter.jalaliToGregorian(this.selectedYear, this.selectedMonth, this.selectedDay);
|
|
var gregStr = pad(g[0]) + '-' + pad(g[1]) + '-' + pad(g[2]);
|
|
if (this.hiddenEl) this.hiddenEl.value = gregStr;
|
|
}
|
|
},
|
|
|
|
open: function () {
|
|
this._render();
|
|
this.container.style.display = 'block';
|
|
this.isOpen = true;
|
|
this._position();
|
|
},
|
|
|
|
close: function () {
|
|
this.container.style.display = 'none';
|
|
this.isOpen = false;
|
|
},
|
|
|
|
_position: function () {
|
|
var rect = this.inputEl.getBoundingClientRect();
|
|
var scrollTop = window.pageYOffset || document.documentElement.scrollTop;
|
|
var scrollLeft = window.pageXOffset || document.documentElement.scrollLeft;
|
|
var pickerHeight = this.container.offsetHeight;
|
|
var spaceBelow = window.innerHeight - rect.bottom;
|
|
var spaceAbove = rect.top;
|
|
|
|
this.container.style.position = 'absolute';
|
|
if (spaceBelow < pickerHeight && spaceAbove > spaceBelow) {
|
|
this.container.style.top = (rect.top + scrollTop - pickerHeight - 4) + 'px';
|
|
} else {
|
|
this.container.style.top = (rect.bottom + scrollTop + 4) + 'px';
|
|
}
|
|
// RTL: align to right edge
|
|
this.container.style.left = (rect.right + scrollLeft - this.container.offsetWidth) + 'px';
|
|
},
|
|
|
|
_render: function () {
|
|
var self = this;
|
|
var y = this.currentYear;
|
|
var m = this.currentMonth;
|
|
|
|
// First day of month in Gregorian
|
|
var firstGreg = JalaliConverter.jalaliToGregorian(y, m, 1);
|
|
var firstDate = new Date(firstGreg[0], firstGreg[1] - 1, firstGreg[2]);
|
|
// Day of week: 0=Sun, adjust to Saturday=0 for Jalali
|
|
var dow = firstDate.getDay(); // 0=Sun
|
|
var jalaliDow = (dow + 1) % 7; // 0=Sat
|
|
|
|
var daysInMonth = JalaliConverter.jalaliMonthDays(y, m);
|
|
|
|
// Today
|
|
var now = new Date();
|
|
var todayJ = JalaliConverter.gregorianToJalali(now.getFullYear(), now.getMonth() + 1, now.getDate());
|
|
|
|
var html = '<div class="jp-header">';
|
|
html += '<button type="button" class="jp-nav jp-prev" data-dir="prev">«</button>';
|
|
html += '<span class="jp-title">' + y + ' ' + jalaliMonths[m - 1] + '</span>';
|
|
html += '<button type="button" class="jp-nav jp-next" data-dir="next">»</button>';
|
|
html += '</div>';
|
|
|
|
// Week day headers
|
|
html += '<div class="jp-weekdays">';
|
|
for (var i = 0; i < 7; i++) {
|
|
html += '<span class="jp-wd">' + jalaliWeekDaysShort[i] + '</span>';
|
|
}
|
|
html += '</div>';
|
|
|
|
// Days grid
|
|
html += '<div class="jp-days">';
|
|
// Empty cells before first day
|
|
for (var i = 0; i < jalaliDow; i++) {
|
|
html += '<span class="jp-day jp-empty"></span>';
|
|
}
|
|
// Day cells
|
|
for (var d = 1; d <= daysInMonth; d++) {
|
|
var classes = 'jp-day';
|
|
if (y === todayJ[0] && m === todayJ[1] && d === todayJ[2]) classes += ' jp-today';
|
|
if (y === this.selectedYear && m === this.selectedMonth && d === this.selectedDay) classes += ' jp-selected';
|
|
html += '<button type="button" class="' + classes + '" data-day="' + d + '">' + d + '</button>';
|
|
}
|
|
html += '</div>';
|
|
|
|
// Footer
|
|
html += '<div class="jp-footer">';
|
|
html += '<button type="button" class="jp-today-btn">امروز</button>';
|
|
html += '</div>';
|
|
|
|
this.container.innerHTML = html;
|
|
|
|
// Bind navigation
|
|
this.container.querySelector('.jp-prev').addEventListener('click', function () { self._navigate(-1); });
|
|
this.container.querySelector('.jp-next').addEventListener('click', function () { self._navigate(1); });
|
|
this.container.querySelector('.jp-today-btn').addEventListener('click', function () { self._goToday(); });
|
|
|
|
// Bind day clicks
|
|
var dayBtns = this.container.querySelectorAll('.jp-day:not(.jp-empty)');
|
|
for (var i = 0; i < dayBtns.length; i++) {
|
|
dayBtns[i].addEventListener('click', function () {
|
|
var day = parseInt(this.getAttribute('data-day'));
|
|
self._selectDay(self.currentYear, self.currentMonth, day);
|
|
});
|
|
}
|
|
},
|
|
|
|
_navigate: function (dir) {
|
|
this.currentMonth += dir;
|
|
if (this.currentMonth > 12) {
|
|
this.currentMonth = 1;
|
|
this.currentYear++;
|
|
} else if (this.currentMonth < 1) {
|
|
this.currentMonth = 12;
|
|
this.currentYear--;
|
|
}
|
|
this._render();
|
|
this._position();
|
|
},
|
|
|
|
_selectDay: function (y, m, d) {
|
|
this.selectedYear = y;
|
|
this.selectedMonth = m;
|
|
this.selectedDay = d;
|
|
this.inputEl.value = pad(y) + '/' + pad(m) + '/' + pad(d);
|
|
this._updateHidden();
|
|
this.close();
|
|
// Trigger change event
|
|
var event = new Event('change', { bubbles: true });
|
|
this.inputEl.dispatchEvent(event);
|
|
if (this.hiddenEl) {
|
|
this.hiddenEl.dispatchEvent(new Event('change', { bubbles: true }));
|
|
}
|
|
},
|
|
|
|
_goToday: function () {
|
|
var now = new Date();
|
|
var today = JalaliConverter.gregorianToJalali(now.getFullYear(), now.getMonth() + 1, now.getDate());
|
|
this._selectDay(today[0], today[1], today[2]);
|
|
}
|
|
};
|
|
|
|
// ─── Initialize all date pickers ───────────────────────────
|
|
function initPickers() {
|
|
var inputs = document.querySelectorAll('.jalali-datepicker-input');
|
|
for (var i = 0; i < inputs.length; i++) {
|
|
var input = inputs[i];
|
|
if (input._jalaliPickerInitialized) continue;
|
|
|
|
var hiddenId = input.getAttribute('data-gregorian-target');
|
|
var hidden = hiddenId ? document.getElementById(hiddenId) : null;
|
|
|
|
new JalaliDatePicker(input, hidden);
|
|
input._jalaliPickerInitialized = true;
|
|
}
|
|
}
|
|
|
|
// Run on DOM ready
|
|
if (document.readyState === 'loading') {
|
|
document.addEventListener('DOMContentLoaded', initPickers);
|
|
} else {
|
|
initPickers();
|
|
}
|
|
|
|
})();
|