Над календарём — две пилюли-конца в стиле чипсов фильтров (accent-weak заливка, индиго-текст, скруглённые). Активная пилюля (куда пишется следующий клик) с индиго-рамкой; клик по дню пишет в активный конец, фокус авто-переходит ко второму. У каждой пилюли × — очистить именно этот конец, что даёт ОТКРЫТЫЙ диапазон: «с X» (без конца) или «по Y» (без начала). «Применить» активна, если задан хотя бы один конец. Нативные #date-start/#date-end по-прежнему источник истины, любой из них может быть пустым; фильтр и URL уже читают from/to по отдельности. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
216 lines
8.8 KiB
TypeScript
216 lines
8.8 KiB
TypeScript
// Custom date-range picker. The two native <input type=date> (#date-start /
|
||
// #date-end) stay in the DOM (hidden) as the source of truth — all filter code
|
||
// keeps reading/writing them. The calendar selects into a temporary state; the
|
||
// filter is applied (and the popup closed) only when the user presses «Применить».
|
||
//
|
||
// Two endpoint pills «С» / «По» sit above the grid: one is active, and clicking a
|
||
// day writes into the active endpoint, then focus auto-advances to the other. Each
|
||
// pill has a × to clear just that endpoint, so an OPEN range is possible — «с X»
|
||
// (no end) or «по Y» (no start). «Применить» is enabled once at least one endpoint
|
||
// is set; «Сбросить» clears both.
|
||
|
||
const MONTHS = ['Январь', 'Февраль', 'Март', 'Апрель', 'Май', 'Июнь', 'Июль', 'Август', 'Сентябрь', 'Октябрь', 'Ноябрь', 'Декабрь'];
|
||
const WEEKDAYS = ['пн', 'вт', 'ср', 'чт', 'пт', 'сб', 'вс'];
|
||
|
||
function parseISO(iso: string): Date | null {
|
||
return iso ? new Date(iso + 'T00:00:00') : null;
|
||
}
|
||
function toISO(d: Date): string {
|
||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||
}
|
||
function fmt(iso: string): string {
|
||
const p = iso.split('-');
|
||
// Short 2-digit year so the full range fits the compact "Период" field.
|
||
return p.length === 3 ? `${p[2]}.${p[1]}.${p[0].slice(2)}` : iso;
|
||
}
|
||
|
||
export interface DateRange {
|
||
/** Refresh the field text (+ open calendar) from the hidden inputs. */
|
||
sync(): void;
|
||
}
|
||
|
||
export function createDateRange(): DateRange {
|
||
const startInput = document.getElementById('date-start') as HTMLInputElement;
|
||
const endInput = document.getElementById('date-end') as HTMLInputElement;
|
||
const field = document.getElementById('dr-field') as HTMLElement;
|
||
const text = document.getElementById('dr-text') as HTMLElement;
|
||
const clearBtn = document.getElementById('dr-clear') as HTMLElement;
|
||
const cal = document.getElementById('dr-cal') as HTMLElement;
|
||
|
||
let viewY = new Date().getFullYear();
|
||
let viewM = new Date().getMonth();
|
||
// In-progress selection (committed to the inputs on «Применить»). Either end may
|
||
// stay empty → an open range. `activeEnd` is the endpoint the next day-click fills.
|
||
let selStart = '';
|
||
let selEnd = '';
|
||
let activeEnd: 'start' | 'end' = 'start';
|
||
|
||
function updateText(): void {
|
||
const s = startInput.value;
|
||
const e = endInput.value;
|
||
if (s && e) text.textContent = `${fmt(s)} — ${fmt(e)}`;
|
||
else if (s) text.textContent = `с ${fmt(s)}`;
|
||
else if (e) text.textContent = `по ${fmt(e)}`;
|
||
else text.textContent = 'Период';
|
||
clearBtn.style.display = s || e ? '' : 'none';
|
||
field.classList.toggle('has-value', !!(s || e));
|
||
}
|
||
|
||
function render(): void {
|
||
const s = parseISO(selStart);
|
||
const e = parseISO(selEnd);
|
||
const endPill = (which: 'start' | 'end', label: string, val: string): string =>
|
||
`<button type="button" class="dr-end${activeEnd === which ? ' active' : ''}${val ? ' set' : ''}" data-end="${which}">` +
|
||
`<span class="dr-end-lbl">${label}</span>` +
|
||
`<span class="dr-end-val">${val ? fmt(val) : '—'}</span>` +
|
||
(val ? `<span class="dr-end-x" data-clear="${which}" aria-label="Очистить">×</span>` : '') +
|
||
`</button>`;
|
||
const parts: string[] = [
|
||
`<div class="dr-ends">${endPill('start', 'С', selStart)}${endPill('end', 'По', selEnd)}</div>`,
|
||
`<div class="dr-cal-h"><button type="button" class="dr-nav" data-nav="-1" aria-label="Предыдущий месяц">‹</button>` +
|
||
`<span class="dr-cal-title">${MONTHS[viewM]} ${viewY}</span>` +
|
||
`<button type="button" class="dr-nav" data-nav="1" aria-label="Следующий месяц">›</button></div>`,
|
||
'<div class="dr-grid">',
|
||
];
|
||
for (const w of WEEKDAYS) parts.push(`<span class="dr-wd">${w}</span>`);
|
||
const lead = (new Date(viewY, viewM, 1).getDay() + 6) % 7; // Monday-first
|
||
const days = new Date(viewY, viewM + 1, 0).getDate();
|
||
for (let i = 0; i < lead; i++) parts.push('<span class="dr-blank"></span>');
|
||
for (let d = 1; d <= days; d++) {
|
||
const cur = new Date(viewY, viewM, d);
|
||
const iso = toISO(cur);
|
||
let cls = 'dr-day';
|
||
if (iso === selStart) cls += ' s';
|
||
if (iso === selEnd) cls += ' e';
|
||
if (s && e && cur > s && cur < e) cls += ' rng';
|
||
parts.push(`<button type="button" class="${cls}" data-iso="${iso}"><span class="dr-num">${d}</span></button>`);
|
||
}
|
||
parts.push('</div>');
|
||
const ready = !!(selStart || selEnd); // at least one endpoint → applyable (open range OK)
|
||
parts.push(
|
||
'<div class="dr-foot">' +
|
||
`<button type="button" class="dr-reset" data-act="reset">Сбросить</button>` +
|
||
`<button type="button" class="dr-apply" data-act="apply"${ready ? '' : ' disabled'}>Применить</button>` +
|
||
'</div>'
|
||
);
|
||
cal.innerHTML = parts.join('');
|
||
}
|
||
|
||
// Write the completed selection to the hidden inputs → triggers the filter reload.
|
||
function applyRange(): void {
|
||
startInput.value = selStart;
|
||
endInput.value = selEnd;
|
||
startInput.dispatchEvent(new Event('change', { bubbles: true }));
|
||
endInput.dispatchEvent(new Event('change', { bubbles: true }));
|
||
updateText();
|
||
}
|
||
|
||
function pick(iso: string): void {
|
||
if (activeEnd === 'start') {
|
||
selStart = iso;
|
||
// Крест начала за концом — конец больше не валиден, очищаем его.
|
||
if (selEnd && selStart > selEnd) selEnd = '';
|
||
if (!selEnd) activeEnd = 'end'; // авто-переход к незаданному концу
|
||
} else {
|
||
selEnd = iso;
|
||
if (selStart && selEnd < selStart) selStart = '';
|
||
if (!selStart) activeEnd = 'start';
|
||
}
|
||
render();
|
||
}
|
||
|
||
function open(): void {
|
||
selStart = startInput.value;
|
||
selEnd = endInput.value;
|
||
// Start filling whichever endpoint is empty (start first).
|
||
activeEnd = !selStart ? 'start' : !selEnd ? 'end' : 'start';
|
||
const base = parseISO(selStart) || parseISO(selEnd) || new Date();
|
||
viewY = base.getFullYear();
|
||
viewM = base.getMonth();
|
||
render();
|
||
cal.hidden = false;
|
||
}
|
||
function close(): void {
|
||
cal.hidden = true;
|
||
}
|
||
|
||
field.addEventListener('click', (ev) => {
|
||
if ((ev.target as HTMLElement).closest('.dr-clear')) return;
|
||
if (cal.hidden) open();
|
||
else close();
|
||
});
|
||
field.addEventListener('keydown', (ev) => {
|
||
if (ev.key === 'Enter' || ev.key === ' ') { ev.preventDefault(); if (cal.hidden) open(); else close(); }
|
||
});
|
||
clearBtn.addEventListener('click', (ev) => {
|
||
ev.stopPropagation();
|
||
selStart = '';
|
||
selEnd = '';
|
||
startInput.value = '';
|
||
endInput.value = '';
|
||
startInput.dispatchEvent(new Event('change', { bubbles: true }));
|
||
endInput.dispatchEvent(new Event('change', { bubbles: true }));
|
||
updateText();
|
||
close();
|
||
});
|
||
cal.addEventListener('click', (ev) => {
|
||
// Keep the click inside the calendar so re-rendering a clicked day doesn't
|
||
// detach the target and trip the outside-click close below.
|
||
ev.stopPropagation();
|
||
const target = ev.target as HTMLElement;
|
||
const nav = target.closest<HTMLElement>('.dr-nav');
|
||
if (nav) {
|
||
viewM += Number(nav.dataset.nav);
|
||
if (viewM < 0) { viewM = 11; viewY--; } else if (viewM > 11) { viewM = 0; viewY++; }
|
||
render();
|
||
return;
|
||
}
|
||
const act = target.closest<HTMLElement>('[data-act]');
|
||
if (act) {
|
||
if (act.dataset.act === 'apply') {
|
||
if (selStart || selEnd) { applyRange(); close(); }
|
||
} else if (act.dataset.act === 'reset') {
|
||
selStart = '';
|
||
selEnd = '';
|
||
activeEnd = 'start';
|
||
render();
|
||
}
|
||
return;
|
||
}
|
||
// Clear one endpoint via its × (→ open range on that side). Checked before the
|
||
// pill activate below, since the × lives inside the pill button.
|
||
const clr = target.closest<HTMLElement>('[data-clear]');
|
||
if (clr) {
|
||
const which = clr.dataset.clear as 'start' | 'end';
|
||
if (which === 'start') selStart = ''; else selEnd = '';
|
||
activeEnd = which; // focus the emptied end for the next pick
|
||
render();
|
||
return;
|
||
}
|
||
const endBtn = target.closest<HTMLElement>('[data-end]');
|
||
if (endBtn) {
|
||
activeEnd = endBtn.dataset.end as 'start' | 'end';
|
||
render();
|
||
return;
|
||
}
|
||
const day = target.closest<HTMLElement>('.dr-day');
|
||
if (day && day.dataset.iso) pick(day.dataset.iso);
|
||
});
|
||
document.addEventListener('click', (ev) => {
|
||
if (!(ev.target as HTMLElement).closest('#daterange')) close();
|
||
});
|
||
|
||
updateText();
|
||
return {
|
||
sync(): void {
|
||
updateText();
|
||
if (!cal.hidden) {
|
||
selStart = startInput.value;
|
||
selEnd = endInput.value;
|
||
activeEnd = !selStart ? 'start' : !selEnd ? 'end' : 'start';
|
||
render();
|
||
}
|
||
},
|
||
};
|
||
}
|