Датапикер: открытый диапазон через пилюли-концы «С» / «По»
Над календарём — две пилюли-конца в стиле чипсов фильтров (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:
parent
5ac2142c70
commit
77a36a22d9
@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "mapdash-frontend",
|
||||
"private": true,
|
||||
"version": "0.2.15",
|
||||
"version": "0.2.16",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@ -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();
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@ -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); }
|
||||
|
||||
Loading…
Reference in New Issue
Block a user