UI-доработки: выравнивание тогглов, датапикер с «Применить», степперы 80px, поле поиска, автозакрытие балуна карты
- Тогглы: Бренд+Компания в одну строку, блок выровнен ровно по полосе контролов (верх = верх полей, низ = низ «Поиска»), равномерные зазоры - Датапикер: выбор диапазона больше не применяется автоматически по второму клику — только кнопкой «Применить» (+ «Сбросить» для незавершённого выбора) - Степперы «Оформления»: ровно 80px по ширине - Поле поиска в дропдаунах: равномерная рамка 1px по всем сторонам вместо разноцветных краёв - Балун на карте: клик по пустой карте закрывает балун (guard от самозакрытия по «своему» клику, клик по другому кластеру открывает новый) - Полоса фильтров: поля сужены на 10px, «Период» до 333px Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
e9eee60dab
commit
a51569beee
@ -98,7 +98,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="field field-dates">
|
<div class="field field-dates">
|
||||||
<label>Период (даты)</label>
|
<label>Период</label>
|
||||||
<div class="daterange" id="daterange">
|
<div class="daterange" id="daterange">
|
||||||
<div class="dr-field" id="dr-field" tabindex="0" role="button" aria-label="Выбрать период">
|
<div class="dr-field" id="dr-field" tabindex="0" role="button" aria-label="Выбрать период">
|
||||||
<svg class="dr-ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="4" width="18" height="17" rx="2"/><line x1="3" y1="9" x2="21" y2="9"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="16" y1="2" x2="16" y2="6"/></svg>
|
<svg class="dr-ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="4" width="18" height="17" rx="2"/><line x1="3" y1="9" x2="21" y2="9"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="16" y1="2" x2="16" y2="6"/></svg>
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "mapdash-frontend",
|
"name": "mapdash-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.2.13",
|
"version": "0.2.14",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@ -1,7 +1,9 @@
|
|||||||
// Custom date-range picker. The two native <input type=date> (#date-start /
|
// 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
|
// #date-end) stay in the DOM (hidden) as the source of truth — all filter code
|
||||||
// keeps reading/writing them — while this renders the field + calendar popup and
|
// keeps reading/writing them. The calendar selects into a temporary state; the
|
||||||
// dispatches `change` on them so the existing reload wiring fires.
|
// filter is applied (and the popup closed) only when the user presses «Применить».
|
||||||
|
// Both endpoints are chosen by clicking days; «Сбросить» clears the in-progress
|
||||||
|
// selection.
|
||||||
|
|
||||||
const MONTHS = ['Январь', 'Февраль', 'Март', 'Апрель', 'Май', 'Июнь', 'Июль', 'Август', 'Сентябрь', 'Октябрь', 'Ноябрь', 'Декабрь'];
|
const MONTHS = ['Январь', 'Февраль', 'Март', 'Апрель', 'Май', 'Июнь', 'Июль', 'Август', 'Сентябрь', 'Октябрь', 'Ноябрь', 'Декабрь'];
|
||||||
const WEEKDAYS = ['пн', 'вт', 'ср', 'чт', 'пт', 'сб', 'вс'];
|
const WEEKDAYS = ['пн', 'вт', 'ср', 'чт', 'пт', 'сб', 'вс'];
|
||||||
@ -32,6 +34,9 @@ export function createDateRange(): DateRange {
|
|||||||
|
|
||||||
let viewY = new Date().getFullYear();
|
let viewY = new Date().getFullYear();
|
||||||
let viewM = new Date().getMonth();
|
let viewM = new Date().getMonth();
|
||||||
|
// In-progress selection (committed to the inputs only when both ends are set).
|
||||||
|
let selStart = '';
|
||||||
|
let selEnd = '';
|
||||||
|
|
||||||
function updateText(): void {
|
function updateText(): void {
|
||||||
const s = startInput.value;
|
const s = startInput.value;
|
||||||
@ -45,8 +50,8 @@ export function createDateRange(): DateRange {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function render(): void {
|
function render(): void {
|
||||||
const s = parseISO(startInput.value);
|
const s = parseISO(selStart);
|
||||||
const e = parseISO(endInput.value);
|
const e = parseISO(selEnd);
|
||||||
const parts: string[] = [
|
const parts: string[] = [
|
||||||
`<div class="dr-cal-h"><button type="button" class="dr-nav" data-nav="-1" aria-label="Предыдущий месяц">‹</button>` +
|
`<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>` +
|
`<span class="dr-cal-title">${MONTHS[viewM]} ${viewY}</span>` +
|
||||||
@ -61,39 +66,50 @@ export function createDateRange(): DateRange {
|
|||||||
const cur = new Date(viewY, viewM, d);
|
const cur = new Date(viewY, viewM, d);
|
||||||
const iso = toISO(cur);
|
const iso = toISO(cur);
|
||||||
let cls = 'dr-day';
|
let cls = 'dr-day';
|
||||||
if (iso === startInput.value) cls += ' s';
|
if (iso === selStart) cls += ' s';
|
||||||
if (iso === endInput.value) cls += ' e';
|
if (iso === selEnd) cls += ' e';
|
||||||
if (s && e && cur > s && cur < e) cls += ' rng';
|
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(`<button type="button" class="${cls}" data-iso="${iso}"><span class="dr-num">${d}</span></button>`);
|
||||||
}
|
}
|
||||||
parts.push('</div>');
|
parts.push('</div>');
|
||||||
|
const ready = !!(selStart && selEnd);
|
||||||
|
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('');
|
cal.innerHTML = parts.join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
function commit(): void {
|
// 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 }));
|
startInput.dispatchEvent(new Event('change', { bubbles: true }));
|
||||||
endInput.dispatchEvent(new Event('change', { bubbles: true }));
|
endInput.dispatchEvent(new Event('change', { bubbles: true }));
|
||||||
updateText();
|
updateText();
|
||||||
}
|
}
|
||||||
|
|
||||||
function pick(iso: string): void {
|
function pick(iso: string): void {
|
||||||
const s = startInput.value;
|
if (!selStart || (selStart && selEnd)) {
|
||||||
const e = endInput.value;
|
// start a fresh selection — first endpoint only
|
||||||
if (!s || (s && e)) {
|
selStart = iso;
|
||||||
// start a fresh range
|
selEnd = '';
|
||||||
startInput.value = iso;
|
} else if (iso >= selStart) {
|
||||||
endInput.value = '';
|
// second endpoint — complete the range, but wait for «Применить»
|
||||||
} else if (iso >= s) {
|
selEnd = iso;
|
||||||
endInput.value = iso; // complete the range
|
|
||||||
} else {
|
} else {
|
||||||
startInput.value = iso; // clicked before the start — move the start
|
// clicked before the start — move the start (still one endpoint)
|
||||||
|
selStart = iso;
|
||||||
}
|
}
|
||||||
commit();
|
|
||||||
render();
|
render();
|
||||||
}
|
}
|
||||||
|
|
||||||
function open(): void {
|
function open(): void {
|
||||||
const base = parseISO(startInput.value) || new Date();
|
selStart = startInput.value;
|
||||||
|
selEnd = endInput.value;
|
||||||
|
const base = parseISO(selStart) || new Date();
|
||||||
viewY = base.getFullYear();
|
viewY = base.getFullYear();
|
||||||
viewM = base.getMonth();
|
viewM = base.getMonth();
|
||||||
render();
|
render();
|
||||||
@ -113,12 +129,19 @@ export function createDateRange(): DateRange {
|
|||||||
});
|
});
|
||||||
clearBtn.addEventListener('click', (ev) => {
|
clearBtn.addEventListener('click', (ev) => {
|
||||||
ev.stopPropagation();
|
ev.stopPropagation();
|
||||||
|
selStart = '';
|
||||||
|
selEnd = '';
|
||||||
startInput.value = '';
|
startInput.value = '';
|
||||||
endInput.value = '';
|
endInput.value = '';
|
||||||
commit();
|
startInput.dispatchEvent(new Event('change', { bubbles: true }));
|
||||||
|
endInput.dispatchEvent(new Event('change', { bubbles: true }));
|
||||||
|
updateText();
|
||||||
close();
|
close();
|
||||||
});
|
});
|
||||||
cal.addEventListener('click', (ev) => {
|
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 target = ev.target as HTMLElement;
|
||||||
const nav = target.closest<HTMLElement>('.dr-nav');
|
const nav = target.closest<HTMLElement>('.dr-nav');
|
||||||
if (nav) {
|
if (nav) {
|
||||||
@ -127,6 +150,17 @@ export function createDateRange(): DateRange {
|
|||||||
render();
|
render();
|
||||||
return;
|
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 = '';
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
const day = target.closest<HTMLElement>('.dr-day');
|
const day = target.closest<HTMLElement>('.dr-day');
|
||||||
if (day && day.dataset.iso) pick(day.dataset.iso);
|
if (day && day.dataset.iso) pick(day.dataset.iso);
|
||||||
});
|
});
|
||||||
@ -138,7 +172,7 @@ export function createDateRange(): DateRange {
|
|||||||
return {
|
return {
|
||||||
sync(): void {
|
sync(): void {
|
||||||
updateText();
|
updateText();
|
||||||
if (!cal.hidden) render();
|
if (!cal.hidden) { selStart = startInput.value; selEnd = endInput.value; render(); }
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -213,6 +213,10 @@ function setupBalloonPhotos(): void {
|
|||||||
interface Panel { el: HTMLElement; title: HTMLElement; tabs: HTMLElement; body: HTMLElement; surfaces: MapSurface[]; }
|
interface Panel { el: HTMLElement; title: HTMLElement; tabs: HTMLElement; body: HTMLElement; surfaces: MapSurface[]; }
|
||||||
interface PanelPlace { x: number; y: number; bounds: DOMRect; }
|
interface PanelPlace { x: number; y: number; bounds: DOMRect; }
|
||||||
let mpanel: Panel | null = null;
|
let mpanel: Panel | null = null;
|
||||||
|
// Timestamp of the last panel open. Yandex fires the map's own 'click' for the
|
||||||
|
// very same click that opened a cluster panel, so the map-click close handler
|
||||||
|
// ignores clicks landing within a short window of an open.
|
||||||
|
let panelOpenedAt = 0;
|
||||||
|
|
||||||
// Open the panel next to the clicked cluster, offset toward the free side: which
|
// Open the panel next to the clicked cluster, offset toward the free side: which
|
||||||
// half of the map the cluster sits in decides the direction (e.g. bottom-left
|
// half of the map the cluster sits in decides the direction (e.g. bottom-left
|
||||||
@ -317,6 +321,7 @@ function showMapPanel(surfaces: MapSurface[], idx: number, place: PanelPlace): v
|
|||||||
p.el.style.animation = 'none';
|
p.el.style.animation = 'none';
|
||||||
void p.el.offsetHeight;
|
void p.el.offsetHeight;
|
||||||
p.el.style.animation = '';
|
p.el.style.animation = '';
|
||||||
|
panelOpenedAt = Date.now();
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeMapPanel(): void {
|
function closeMapPanel(): void {
|
||||||
@ -608,6 +613,12 @@ export function createMapView(els: MapElements, apiKey: string): MapView {
|
|||||||
const list = objs.map((o: any) => drawn[o.id as number]).filter(Boolean);
|
const list = objs.map((o: any) => drawn[o.id as number]).filter(Boolean);
|
||||||
if (list.length) showMapPanel(list, 0, clusterPlace(mx, my));
|
if (list.length) showMapPanel(list, 0, clusterPlace(mx, my));
|
||||||
});
|
});
|
||||||
|
// Clicking the empty map (anywhere but a cluster) closes an open panel.
|
||||||
|
// Yandex also fires this map 'click' for the same click that opened a
|
||||||
|
// cluster panel, so skip closes within a short window of an open.
|
||||||
|
map.events.add('click', () => {
|
||||||
|
if (Date.now() - panelOpenedAt > 250) closeMapPanel();
|
||||||
|
});
|
||||||
|
|
||||||
if (pending) {
|
if (pending) {
|
||||||
draw(pending);
|
draw(pending);
|
||||||
|
|||||||
@ -107,7 +107,7 @@ header {
|
|||||||
.hgroup-fields {
|
.hgroup-fields {
|
||||||
flex-shrink: 4; min-width: 0;
|
flex-shrink: 4; min-width: 0;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(4, minmax(130px, 1fr));
|
grid-template-columns: repeat(4, 170px);
|
||||||
gap: 12px 14px;
|
gap: 12px 14px;
|
||||||
align-items: start;
|
align-items: start;
|
||||||
align-content: start;
|
align-content: start;
|
||||||
@ -124,6 +124,7 @@ header {
|
|||||||
|
|
||||||
/* ---- date-range field + calendar popup ---- */
|
/* ---- date-range field + calendar popup ---- */
|
||||||
.field-dates { grid-column: span 2; }
|
.field-dates { grid-column: span 2; }
|
||||||
|
.field-dates .dr-field { max-width: 333px; } /* the "Период" field is 40px narrower than its 2-col slot */
|
||||||
.dr-native { display: none; }
|
.dr-native { display: none; }
|
||||||
.daterange { position: relative; }
|
.daterange { position: relative; }
|
||||||
.dr-field {
|
.dr-field {
|
||||||
@ -160,6 +161,12 @@ header {
|
|||||||
.dr-day:hover .dr-num { background: var(--surface-2); }
|
.dr-day:hover .dr-num { background: var(--surface-2); }
|
||||||
.dr-day.s .dr-num, .dr-day.e .dr-num { background: var(--accent); color: #fff; font-weight: 600; }
|
.dr-day.s .dr-num, .dr-day.e .dr-num { background: var(--accent); color: #fff; font-weight: 600; }
|
||||||
.dr-day.s:hover .dr-num, .dr-day.e:hover .dr-num { background: var(--accent); }
|
.dr-day.s:hover .dr-num, .dr-day.e:hover .dr-num { background: var(--accent); }
|
||||||
|
.dr-foot { display: flex; align-items: center; justify-content: flex-end; gap: 8px; margin-top: 10px; padding-top: 10px; border-top: 1px solid var(--border); }
|
||||||
|
.dr-reset { border: none; background: none; color: var(--text-muted); cursor: pointer; font-size: 13px; padding: 7px 10px; border-radius: var(--radius-sm); }
|
||||||
|
.dr-reset:hover { background: var(--surface); color: var(--text); }
|
||||||
|
.dr-apply { border: none; background: var(--accent); color: #fff; cursor: pointer; font-size: 13px; font-weight: 600; padding: 7px 16px; border-radius: var(--radius-sm); transition: background var(--transition); }
|
||||||
|
.dr-apply:hover { background: var(--accent-strong, #4338ca); }
|
||||||
|
.dr-apply:disabled { opacity: .45; cursor: default; }
|
||||||
.fcol { display: flex; flex-direction: column; gap: 8px; }
|
.fcol { display: flex; flex-direction: column; gap: 8px; }
|
||||||
|
|
||||||
input[type=date],
|
input[type=date],
|
||||||
@ -200,7 +207,10 @@ input::placeholder { color: var(--text-muted); }
|
|||||||
top, the three checkboxes stacked below. */
|
top, the three checkboxes stacked below. */
|
||||||
.hgroup-controls { flex-direction: column; align-items: flex-start; gap: 8px; }
|
.hgroup-controls { flex-direction: column; align-items: flex-start; gap: 8px; }
|
||||||
.zoom-row { display: flex; align-items: center; gap: 8px; }
|
.zoom-row { display: flex; align-items: center; gap: 8px; }
|
||||||
.checks { display: flex; flex-direction: column; align-items: flex-start; gap: 6px; }
|
/* Toggles: aligned to the filter *controls* (not the labels) — Бренд + Компания
|
||||||
|
share the top row, then Коллизии and Все поверхности, evenly distributed
|
||||||
|
between the top of the first control box and the bottom of Поиск. */
|
||||||
|
.checks { display: flex; flex-direction: column; align-items: flex-start; justify-content: space-between; flex: 1; align-self: stretch; padding: 19px 0 0; }
|
||||||
.checks .checkbox-field { align-self: flex-start; }
|
.checks .checkbox-field { align-self: flex-start; }
|
||||||
.checks-row { display: flex; flex-direction: row; align-items: center; gap: 18px; }
|
.checks-row { display: flex; flex-direction: row; align-items: center; gap: 18px; }
|
||||||
|
|
||||||
@ -313,14 +323,14 @@ input::placeholder { color: var(--text-muted); }
|
|||||||
}
|
}
|
||||||
.dropdown-panel .dp-actions a { font-size: 11px; color: var(--accent); cursor: pointer; text-decoration: none; font-weight: 600; }
|
.dropdown-panel .dp-actions a { font-size: 11px; color: var(--accent); cursor: pointer; text-decoration: none; font-weight: 600; }
|
||||||
.dropdown-panel .dp-actions a:hover { text-decoration: underline; }
|
.dropdown-panel .dp-actions a:hover { text-decoration: underline; }
|
||||||
/* In-dropdown search that filters the option list. */
|
/* In-dropdown search that filters the option list — a clean, evenly-bordered box. */
|
||||||
.dropdown-panel .dp-search {
|
.dropdown-panel .dp-search {
|
||||||
display: block; width: 100%; box-sizing: border-box; padding: 8px 12px;
|
display: block; width: calc(100% - 16px); box-sizing: border-box; margin: 3px 8px 6px;
|
||||||
font-size: 13px; color: var(--text); background: var(--bg);
|
padding: 7px 10px; font-size: 13px; color: var(--text); background: var(--surface);
|
||||||
border: none; border-bottom: 1px solid var(--border); outline: none;
|
border: 1px solid var(--border); border-radius: var(--radius-sm); outline: none;
|
||||||
}
|
}
|
||||||
.dropdown-panel .dp-search::placeholder { color: var(--text-muted); }
|
.dropdown-panel .dp-search::placeholder { color: var(--text-muted); }
|
||||||
.dropdown-panel .dp-search:focus { border-bottom-color: var(--accent); }
|
.dropdown-panel .dp-search:focus { border-color: var(--border-strong); background: var(--bg); }
|
||||||
|
|
||||||
/* Оформление button + context menu */
|
/* Оформление button + context menu */
|
||||||
/* Pinned to the header's bottom-right corner. */
|
/* Pinned to the header's bottom-right corner. */
|
||||||
@ -371,12 +381,12 @@ input::placeholder { color: var(--text-muted); }
|
|||||||
.decor-stepper { display: inline-flex; align-items: center; border: 1px solid var(--border); border-radius: var(--radius-sm); overflow: hidden; }
|
.decor-stepper { display: inline-flex; align-items: center; border: 1px solid var(--border); border-radius: var(--radius-sm); overflow: hidden; }
|
||||||
.decor-stepper .ds-btn {
|
.decor-stepper .ds-btn {
|
||||||
display: flex; align-items: center; justify-content: center; padding: 0;
|
display: flex; align-items: center; justify-content: center; padding: 0;
|
||||||
width: 22px; height: 28px; border: none; background: var(--surface);
|
width: 20px; height: 28px; border: none; background: var(--surface);
|
||||||
color: var(--accent); font-size: 16px; line-height: 1; cursor: pointer;
|
color: var(--accent); font-size: 15px; line-height: 1; cursor: pointer;
|
||||||
transition: background var(--transition);
|
transition: background var(--transition);
|
||||||
}
|
}
|
||||||
.decor-stepper .ds-btn:hover { background: var(--surface-2); }
|
.decor-stepper .ds-btn:hover { background: var(--surface-2); }
|
||||||
.decor-stepper .ds-input {
|
.decor-stepper input.ds-input {
|
||||||
width: 38px; height: 28px; text-align: center; padding: 0 2px;
|
width: 38px; height: 28px; text-align: center; padding: 0 2px;
|
||||||
border: none; border-left: 1px solid var(--border); border-right: 1px solid var(--border);
|
border: none; border-left: 1px solid var(--border); border-right: 1px solid var(--border);
|
||||||
border-radius: 0; font-size: 13px; background: var(--bg); color: var(--text);
|
border-radius: 0; font-size: 13px; background: var(--bg); color: var(--text);
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user