vernova/public/js/jalali-datepicker.js
2026-07-26 19:58:27 +03:30

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">&laquo;</button>';
html += '<span class="jp-title">' + y + ' ' + jalaliMonths[m - 1] + '</span>';
html += '<button type="button" class="jp-nav jp-next" data-dir="next">&raquo;</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();
}
})();