Датапикер: открытый диапазон через пилюли-концы «С» / «По»

Над календарём — две пилюли-конца в стиле чипсов фильтров (accent-weak
заливка, индиго-текст, скруглённые). Активная пилюля (куда пишется следующий
клик) с индиго-рамкой; клик по дню пишет в активный конец, фокус авто-переходит
ко второму. У каждой пилюли × — очистить именно этот конец, что даёт ОТКРЫТЫЙ
диапазон: «с X» (без конца) или «по Y» (без начала). «Применить» активна, если
задан хотя бы один конец. Нативные #date-start/#date-end по-прежнему источник
истины, любой из них может быть пустым; фильтр и URL уже читают from/to по
отдельности.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
aaverbitskiy 2026-08-17 08:14:45 +00:00
parent 5ac2142c70
commit 77a36a22d9
3 changed files with 64 additions and 16 deletions

View File

@ -1,7 +1,7 @@
{
"name": "mapdash-frontend",
"private": true,
"version": "0.2.15",
"version": "0.2.16",
"type": "module",
"scripts": {
"dev": "vite",

View File

@ -2,8 +2,12 @@
// #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 «Применить».
// Both endpoints are chosen by clicking days; «Сбросить» clears the in-progress
// selection.
//
// 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 = ['пн', 'вт', 'ср', 'чт', 'пт', 'сб', 'вс'];
@ -35,9 +39,11 @@ export function createDateRange(): DateRange {
let viewY = new Date().getFullYear();
let viewM = new Date().getMonth();
// In-progress selection (committed to the inputs only when both ends are set).
// 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;
@ -53,7 +59,14 @@ export function createDateRange(): DateRange {
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>`,
@ -73,7 +86,7 @@ export function createDateRange(): DateRange {
parts.push(`<button type="button" class="${cls}" data-iso="${iso}"><span class="dr-num">${d}</span></button>`);
}
parts.push('</div>');
const ready = !!(selStart && selEnd);
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>` +
@ -93,16 +106,15 @@ export function createDateRange(): DateRange {
}
function pick(iso: string): void {
if (!selStart || (selStart && selEnd)) {
// start a fresh selection — first endpoint only
if (activeEnd === 'start') {
selStart = iso;
selEnd = '';
} else if (iso >= selStart) {
// second endpoint — complete the range, but wait for «Применить»
selEnd = iso;
// Крест начала за концом — конец больше не валиден, очищаем его.
if (selEnd && selStart > selEnd) selEnd = '';
if (!selEnd) activeEnd = 'end'; // авто-переход к незаданному концу
} else {
// clicked before the start — move the start (still one endpoint)
selStart = iso;
selEnd = iso;
if (selStart && selEnd < selStart) selStart = '';
if (!selStart) activeEnd = 'start';
}
render();
}
@ -110,7 +122,9 @@ export function createDateRange(): DateRange {
function open(): void {
selStart = startInput.value;
selEnd = endInput.value;
const base = parseISO(selStart) || new Date();
// 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();
@ -154,14 +168,31 @@ export function createDateRange(): DateRange {
const act = target.closest<HTMLElement>('[data-act]');
if (act) {
if (act.dataset.act === 'apply') {
if (selStart && selEnd) { applyRange(); close(); }
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);
});
@ -173,7 +204,12 @@ export function createDateRange(): DateRange {
return {
sync(): void {
updateText();
if (!cal.hidden) { selStart = startInput.value; selEnd = endInput.value; render(); }
if (!cal.hidden) {
selStart = startInput.value;
selEnd = endInput.value;
activeEnd = !selStart ? 'start' : !selEnd ? 'end' : 'start';
render();
}
},
};
}

View File

@ -144,6 +144,18 @@ header {
box-shadow: var(--shadow-md); padding: 10px; font-variant-numeric: tabular-nums;
}
.dr-cal[hidden] { display: none; }
/* Endpoint pills «С» / «По» above the calendar styled like the active-filter
chips (accent-weak fill, accent text, pill shape); the active one (next click
target) gets an accent border; × clears just that endpoint. */
.dr-ends { display: flex; gap: 8px; margin-bottom: 10px; }
.dr-end { flex: 1; min-width: 0; display: inline-flex; align-items: center; gap: 5px; padding: 4px 5px 4px 10px; border: 1px solid transparent; border-radius: 999px; background: var(--accent-weak); color: var(--accent); font-size: 12px; font-weight: 500; cursor: pointer; text-align: left; }
.dr-end:hover { border-color: var(--accent-ring); }
.dr-end.active { border-color: var(--accent); }
.dr-end-lbl { flex: 0 0 auto; opacity: .65; }
.dr-end-val { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-variant-numeric: tabular-nums; }
.dr-end:not(.set) .dr-end-val { opacity: .55; }
.dr-end-x { flex: 0 0 auto; width: 18px; height: 18px; display: inline-flex; align-items: center; justify-content: center; border-radius: 50%; color: inherit; font-size: 15px; line-height: 1; }
.dr-end-x:hover { background: rgba(79, 70, 229, .18); }
.dr-cal-h { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; }
.dr-cal-title { font-size: 13px; font-weight: 600; color: var(--text); }
.dr-nav { border: none; background: none; color: var(--text-muted); cursor: pointer; font-size: 18px; line-height: 1; width: 26px; height: 26px; border-radius: var(--radius-sm); }