ui: active-filter chips, empty/loading states, focus/animation polish
- Active-filter chips row under the header: one chip per selected city / brand / dimension / manager / status plus search and date bounds, each removable; a "Сбросить всё" clears everything. Dropdown gains setSelected()/clear(). - Empty states for the timeline and map: icon + title + subtitle + "Сбросить фильтры" button (wired to the same reset). (empty toggled to display:flex.) - Delayed indeterminate loading bar (250ms threshold so fast loads don't flash), covering both the data and the map fetches via an in-flight counter. - Polish: pop-in animation for dropdown/appearance menus, :focus-visible accent rings, :active press on buttons, thin themed scrollbars in panels, tabular figures on the timeline date axis and tooltips. New components use the Open Props --size-* spacing scale. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
17c54db501
commit
52a1a7cb2d
@ -13,6 +13,8 @@
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
||||||
|
<div id="load-bar" aria-hidden="true"></div>
|
||||||
|
|
||||||
<header>
|
<header>
|
||||||
<!-- Group 1: brand + counters -->
|
<!-- Group 1: brand + counters -->
|
||||||
<div class="hgroup hgroup-brand">
|
<div class="hgroup hgroup-brand">
|
||||||
@ -127,12 +129,19 @@
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
<div id="filter-chips" class="chips" style="display:none"></div>
|
||||||
|
|
||||||
<div id="view" class="mode-timeline">
|
<div id="view" class="mode-timeline">
|
||||||
<div id="pane-timeline">
|
<div id="pane-timeline">
|
||||||
<div id="chart-wrap">
|
<div id="chart-wrap">
|
||||||
<div id="timeline"></div>
|
<div id="timeline"></div>
|
||||||
<div id="col-resizer" title="Потяните, чтобы изменить ширину колонки"></div>
|
<div id="col-resizer" title="Потяните, чтобы изменить ширину колонки"></div>
|
||||||
<div id="empty" style="display:none">Нет данных по выбранным фильтрам</div>
|
<div id="empty" class="empty-state" style="display:none">
|
||||||
|
<svg class="empty-ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="11" cy="11" r="7"/><line x1="21" y1="21" x2="16.65" y2="16.65"/><line x1="8" y1="11" x2="14" y2="11"/></svg>
|
||||||
|
<div class="empty-title">Ничего не найдено</div>
|
||||||
|
<div class="empty-sub">Под текущие фильтры нет броней</div>
|
||||||
|
<button type="button" class="empty-reset" id="empty-reset-timeline">Сбросить фильтры</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="split-resizer" title="Потяните, чтобы изменить пропорции"></div>
|
<div id="split-resizer" title="Потяните, чтобы изменить пропорции"></div>
|
||||||
@ -145,7 +154,12 @@
|
|||||||
<button type="button" class="lg-nocoords" id="map-nocoords-btn" style="display:none"></button>
|
<button type="button" class="lg-nocoords" id="map-nocoords-btn" style="display:none"></button>
|
||||||
</div>
|
</div>
|
||||||
<div id="map-nocoords-panel" style="display:none"></div>
|
<div id="map-nocoords-panel" style="display:none"></div>
|
||||||
<div id="map-empty" style="display:none">Нет поверхностей с координатами</div>
|
<div id="map-empty" class="empty-state" style="display:none">
|
||||||
|
<svg class="empty-ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 21s-7-6.3-7-11a7 7 0 0 1 14 0c0 4.7-7 11-7 11z"/><circle cx="12" cy="10" r="2.5"/></svg>
|
||||||
|
<div class="empty-title">Нет поверхностей на карте</div>
|
||||||
|
<div class="empty-sub">У выбранных поверхностей нет координат</div>
|
||||||
|
<button type="button" class="empty-reset" id="empty-reset-map">Сбросить фильтры</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -7,6 +7,10 @@ export interface Dropdown {
|
|||||||
/** Replace the available options while keeping the current selection. */
|
/** Replace the available options while keeping the current selection. */
|
||||||
updateValues(values: string[]): void;
|
updateValues(values: string[]): void;
|
||||||
getSelected(): string[];
|
getSelected(): string[];
|
||||||
|
/** Replace the current selection (does NOT fire onChange — caller reloads). */
|
||||||
|
setSelected(values: string[]): void;
|
||||||
|
/** Clear the selection (does NOT fire onChange — caller reloads). */
|
||||||
|
clear(): void;
|
||||||
onChange(fn: () => void): void;
|
onChange(fn: () => void): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -108,6 +112,16 @@ export function createDropdown(id: string): Dropdown {
|
|||||||
getSelected(): string[] {
|
getSelected(): string[] {
|
||||||
return Array.from(selected);
|
return Array.from(selected);
|
||||||
},
|
},
|
||||||
|
setSelected(values: string[]): void {
|
||||||
|
selected = new Set(values);
|
||||||
|
syncCheckboxes();
|
||||||
|
updateBtnText();
|
||||||
|
},
|
||||||
|
clear(): void {
|
||||||
|
selected.clear();
|
||||||
|
syncCheckboxes();
|
||||||
|
updateBtnText();
|
||||||
|
},
|
||||||
onChange(fn: () => void): void {
|
onChange(fn: () => void): void {
|
||||||
onChange = fn;
|
onChange = fn;
|
||||||
},
|
},
|
||||||
|
|||||||
@ -45,6 +45,10 @@ const el = {
|
|||||||
mapEmpty: document.getElementById('map-empty') as HTMLElement,
|
mapEmpty: document.getElementById('map-empty') as HTMLElement,
|
||||||
mapCounter: document.getElementById('map-counter') as HTMLElement,
|
mapCounter: document.getElementById('map-counter') as HTMLElement,
|
||||||
themeToggle: document.getElementById('theme-toggle') as HTMLButtonElement,
|
themeToggle: document.getElementById('theme-toggle') as HTMLButtonElement,
|
||||||
|
filterChips: document.getElementById('filter-chips') as HTMLElement,
|
||||||
|
loadBar: document.getElementById('load-bar') as HTMLElement,
|
||||||
|
emptyResetTimeline: document.getElementById('empty-reset-timeline') as HTMLButtonElement,
|
||||||
|
emptyResetMap: document.getElementById('empty-reset-map') as HTMLButtonElement,
|
||||||
};
|
};
|
||||||
|
|
||||||
// ---- theme toggle (data-theme is set pre-paint by the inline head script) ----
|
// ---- theme toggle (data-theme is set pre-paint by the inline head script) ----
|
||||||
@ -137,23 +141,94 @@ document.addEventListener('click', (e) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---- loading indicator (delayed, so fast loads don't flash) ----
|
||||||
|
let loadTimer: number | undefined;
|
||||||
|
let inflight = 0;
|
||||||
|
function loadStart(): void {
|
||||||
|
inflight++;
|
||||||
|
clearTimeout(loadTimer);
|
||||||
|
loadTimer = window.setTimeout(() => el.loadBar.classList.add('active'), 250);
|
||||||
|
}
|
||||||
|
function loadEnd(): void {
|
||||||
|
inflight = Math.max(0, inflight - 1);
|
||||||
|
if (inflight === 0) {
|
||||||
|
clearTimeout(loadTimer);
|
||||||
|
el.loadBar.classList.remove('active');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- filters: reset + active-filter chips ----
|
||||||
|
const fmtD = (iso: string): string => {
|
||||||
|
const p = iso.split('-');
|
||||||
|
return p.length === 3 ? `${p[2]}.${p[1]}.${p[0]}` : iso;
|
||||||
|
};
|
||||||
|
function resetFilters(): void {
|
||||||
|
for (const d of [cityDropdown, brandDropdown, dimensionDropdown, managerDropdown, statusDropdown]) d.clear();
|
||||||
|
el.search.value = '';
|
||||||
|
el.dateStart.value = '';
|
||||||
|
el.dateEnd.value = '';
|
||||||
|
void loadData(true);
|
||||||
|
}
|
||||||
|
function renderChips(): void {
|
||||||
|
const items: { label: string; remove: () => void }[] = [];
|
||||||
|
for (const dd of [cityDropdown, brandDropdown, dimensionDropdown, managerDropdown, statusDropdown]) {
|
||||||
|
for (const v of dd.getSelected()) {
|
||||||
|
items.push({ label: v, remove: () => { dd.setSelected(dd.getSelected().filter((x) => x !== v)); void loadData(true); } });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (el.search.value.trim()) items.push({ label: 'Поиск: ' + el.search.value.trim(), remove: () => { el.search.value = ''; void loadData(true); } });
|
||||||
|
if (el.dateStart.value) items.push({ label: 'с ' + fmtD(el.dateStart.value), remove: () => { el.dateStart.value = ''; void loadData(true); } });
|
||||||
|
if (el.dateEnd.value) items.push({ label: 'по ' + fmtD(el.dateEnd.value), remove: () => { el.dateEnd.value = ''; void loadData(true); } });
|
||||||
|
|
||||||
|
el.filterChips.innerHTML = '';
|
||||||
|
if (!items.length) { el.filterChips.style.display = 'none'; return; }
|
||||||
|
el.filterChips.style.display = '';
|
||||||
|
for (const it of items) {
|
||||||
|
const chip = document.createElement('span');
|
||||||
|
chip.className = 'chip';
|
||||||
|
chip.appendChild(document.createTextNode(it.label));
|
||||||
|
const x = document.createElement('button');
|
||||||
|
x.type = 'button';
|
||||||
|
x.className = 'chip-x';
|
||||||
|
x.setAttribute('aria-label', 'Убрать фильтр');
|
||||||
|
x.textContent = '×';
|
||||||
|
x.addEventListener('click', it.remove);
|
||||||
|
chip.appendChild(x);
|
||||||
|
el.filterChips.appendChild(chip);
|
||||||
|
}
|
||||||
|
const clear = document.createElement('button');
|
||||||
|
clear.type = 'button';
|
||||||
|
clear.className = 'chips-clear';
|
||||||
|
clear.textContent = 'Сбросить всё';
|
||||||
|
clear.addEventListener('click', resetFilters);
|
||||||
|
el.filterChips.appendChild(clear);
|
||||||
|
}
|
||||||
|
el.emptyResetTimeline.addEventListener('click', resetFilters);
|
||||||
|
el.emptyResetMap.addEventListener('click', resetFilters);
|
||||||
|
|
||||||
async function loadData(fit: boolean): Promise<void> {
|
async function loadData(fit: boolean): Promise<void> {
|
||||||
el.status.textContent = 'Загрузка…';
|
el.status.textContent = 'Загрузка…';
|
||||||
const f = currentFilters();
|
loadStart();
|
||||||
const [meta, boards, bookings] = await Promise.all([api.meta(f), api.boards(f), api.bookings(f)]);
|
try {
|
||||||
// Dependent filters: refresh each dropdown's options (selections preserved).
|
const f = currentFilters();
|
||||||
cityDropdown.updateValues(meta.cities);
|
const [meta, boards, bookings] = await Promise.all([api.meta(f), api.boards(f), api.bookings(f)]);
|
||||||
brandDropdown.updateValues(meta.brands);
|
// Dependent filters: refresh each dropdown's options (selections preserved).
|
||||||
dimensionDropdown.updateValues(meta.dimensions);
|
cityDropdown.updateValues(meta.cities);
|
||||||
managerDropdown.updateValues(meta.managers);
|
brandDropdown.updateValues(meta.brands);
|
||||||
statusDropdown.updateValues(meta.statuses);
|
dimensionDropdown.updateValues(meta.dimensions);
|
||||||
lastBoards = boards;
|
managerDropdown.updateValues(meta.managers);
|
||||||
lastBookings = bookings;
|
statusDropdown.updateValues(meta.statuses);
|
||||||
view.render(boards, bookings, fit, flags());
|
lastBoards = boards;
|
||||||
el.status.innerHTML = `Поверхности: ${boards.length}<br>Брони: ${bookings.length}`;
|
lastBookings = bookings;
|
||||||
rebuildDecorMenu(); // status list may have changed
|
view.render(boards, bookings, fit, flags());
|
||||||
// Keep the map in sync when it is visible (facets filter it; dates do not).
|
el.status.innerHTML = `Поверхности: ${boards.length}<br>Брони: ${bookings.length}`;
|
||||||
if (mapVisible()) void refreshMap();
|
rebuildDecorMenu(); // status list may have changed
|
||||||
|
renderChips();
|
||||||
|
// Keep the map in sync when it is visible (facets filter it; dates do not).
|
||||||
|
if (mapVisible()) void refreshMap();
|
||||||
|
} finally {
|
||||||
|
loadEnd();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- map ----
|
// ---- map ----
|
||||||
@ -168,6 +243,7 @@ function mapVisible(): boolean {
|
|||||||
return !!viewModes && viewModes.getMode() !== 'timeline';
|
return !!viewModes && viewModes.getMode() !== 'timeline';
|
||||||
}
|
}
|
||||||
async function refreshMap(): Promise<void> {
|
async function refreshMap(): Promise<void> {
|
||||||
|
loadStart();
|
||||||
try {
|
try {
|
||||||
await mapView.ensureInit();
|
await mapView.ensureInit();
|
||||||
mapView.invalidateSize();
|
mapView.invalidateSize();
|
||||||
@ -178,6 +254,8 @@ async function refreshMap(): Promise<void> {
|
|||||||
mapView.render(surfaces);
|
mapView.render(surfaces);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('map load failed', e);
|
console.error('map load failed', e);
|
||||||
|
} finally {
|
||||||
|
loadEnd();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -241,7 +241,7 @@ export function createMapView(els: MapElements, apiKey: string): MapView {
|
|||||||
if (!noCoordsList.length) noCoordsPanel.style.display = 'none';
|
if (!noCoordsList.length) noCoordsPanel.style.display = 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
els.empty.style.display = withCoords.length ? 'none' : 'block';
|
els.empty.style.display = withCoords.length ? 'none' : 'flex';
|
||||||
|
|
||||||
if (features.length) {
|
if (features.length) {
|
||||||
const points = features.map((f) => f.geometry.coordinates);
|
const points = features.map((f) => f.geometry.coordinates);
|
||||||
|
|||||||
@ -397,10 +397,7 @@ input::placeholder { color: var(--text-muted); }
|
|||||||
.bl-dates { color: var(--text-muted); }
|
.bl-dates { color: var(--text-muted); }
|
||||||
.bl-link { color: var(--accent); text-decoration: none; font-weight: 600; }
|
.bl-link { color: var(--accent); text-decoration: none; font-weight: 600; }
|
||||||
.bl-link:hover { text-decoration: underline; }
|
.bl-link:hover { text-decoration: underline; }
|
||||||
#map-empty {
|
#map-empty.empty-state { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); z-index: 5; }
|
||||||
position: absolute; top: 50%; left: 50%; transform: translate(-50%,-50%);
|
|
||||||
color: var(--text-muted); font-size: 14px; z-index: 5;
|
|
||||||
}
|
|
||||||
/* Custom hover tooltip for map markers and clusters. */
|
/* Custom hover tooltip for map markers and clusters. */
|
||||||
.map-tip {
|
.map-tip {
|
||||||
position: fixed; z-index: 1000; pointer-events: none;
|
position: fixed; z-index: 1000; pointer-events: none;
|
||||||
@ -514,4 +511,69 @@ input::placeholder { color: var(--text-muted); }
|
|||||||
.tt-warn-row { font-size: 11.5px; color: #f0d9b0; margin: 2px 0; }
|
.tt-warn-row { font-size: 11.5px; color: #f0d9b0; margin: 2px 0; }
|
||||||
.tt-warn-dates { color: var(--tip-sub); }
|
.tt-warn-dates { color: var(--tip-sub); }
|
||||||
.tt-hint { margin-top: 9px; color: var(--tip-sub); font-size: 11px; }
|
.tt-hint { margin-top: 9px; color: var(--tip-sub); font-size: 11px; }
|
||||||
#empty { padding: 40px; text-align: center; color: var(--text-muted); font-size: 13px; }
|
/* ---- empty states (timeline + map) ---- */
|
||||||
|
.empty-state {
|
||||||
|
display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||||||
|
gap: var(--size-2); text-align: center; color: var(--text-muted); padding: var(--size-5);
|
||||||
|
}
|
||||||
|
#empty.empty-state { position: absolute; inset: 0; }
|
||||||
|
.empty-ico { width: 40px; height: 40px; color: var(--border-strong); }
|
||||||
|
.empty-title { font-size: 15px; font-weight: 600; color: var(--text); }
|
||||||
|
.empty-sub { font-size: 13px; color: var(--text-muted); }
|
||||||
|
.empty-reset {
|
||||||
|
margin-top: var(--size-1); border: 1px solid var(--border); background: var(--bg); color: var(--accent);
|
||||||
|
font-size: 13px; font-weight: 500; padding: 7px 14px; border-radius: var(--radius-sm); cursor: pointer;
|
||||||
|
transition: border-color var(--transition), background var(--transition);
|
||||||
|
}
|
||||||
|
.empty-reset:hover { border-color: var(--accent); background: var(--accent-weak); }
|
||||||
|
|
||||||
|
/* ---- loading bar (delayed, indeterminate) ---- */
|
||||||
|
#load-bar {
|
||||||
|
position: fixed; top: 0; left: 0; right: 0; height: 3px; z-index: 200; overflow: hidden;
|
||||||
|
opacity: 0; transition: opacity .2s var(--ease-3); pointer-events: none;
|
||||||
|
}
|
||||||
|
#load-bar.active { opacity: 1; }
|
||||||
|
#load-bar::before {
|
||||||
|
content: ""; position: absolute; top: 0; bottom: 0; width: 35%;
|
||||||
|
background: var(--accent); border-radius: 0 3px 3px 0; animation: loadbar 1.1s var(--ease-3) infinite;
|
||||||
|
}
|
||||||
|
@keyframes loadbar { 0% { left: -35%; } 100% { left: 100%; } }
|
||||||
|
|
||||||
|
/* ---- active-filter chips ---- */
|
||||||
|
.chips {
|
||||||
|
flex: 0 0 auto; display: flex; flex-wrap: wrap; align-items: center; gap: var(--size-2);
|
||||||
|
padding: var(--size-2) var(--size-3); border-bottom: 1px solid var(--border); background: var(--bg); z-index: 15;
|
||||||
|
}
|
||||||
|
.chip {
|
||||||
|
display: inline-flex; align-items: center; gap: 5px;
|
||||||
|
background: var(--accent-weak); color: var(--accent);
|
||||||
|
font-size: 12px; font-weight: 500; padding: 3px 4px 3px 10px; border-radius: 999px; white-space: nowrap;
|
||||||
|
}
|
||||||
|
.chip-x {
|
||||||
|
border: none; background: transparent; color: inherit; cursor: pointer; line-height: 1; font-size: 15px;
|
||||||
|
width: 18px; height: 18px; border-radius: 50%; display: inline-flex; align-items: center; justify-content: center;
|
||||||
|
transition: background var(--transition);
|
||||||
|
}
|
||||||
|
.chip-x:hover { background: rgba(79, 70, 229, .18); }
|
||||||
|
.chips-clear {
|
||||||
|
border: none; background: transparent; color: var(--text-muted); cursor: pointer;
|
||||||
|
font-size: 12px; font-weight: 500; padding: 3px 8px; border-radius: var(--radius-sm); margin-left: 2px;
|
||||||
|
transition: color var(--transition), background var(--transition);
|
||||||
|
}
|
||||||
|
.chips-clear:hover { color: var(--text); background: var(--surface-2); }
|
||||||
|
|
||||||
|
/* ---- polish: focus rings, animations, scrollbars, tabular figures ---- */
|
||||||
|
button:focus-visible, input[type=text]:focus-visible, input[type=date]:focus-visible,
|
||||||
|
.dropdown-btn:focus-visible, .mode-btn:focus-visible, a:focus-visible {
|
||||||
|
outline: none; box-shadow: 0 0 0 3px var(--accent-ring);
|
||||||
|
}
|
||||||
|
input[type=checkbox]:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||||
|
.dropdown.open .dropdown-panel, .decor-menu.open { animation: pop-in .13s var(--ease-3); }
|
||||||
|
@keyframes pop-in { from { opacity: 0; transform: translateY(-4px); } to { opacity: 1; transform: translateY(0); } }
|
||||||
|
.mode-btn:active, .decor-btn:active, .empty-reset:active, .chips-clear:active, .lg-nocoords:active { transform: scale(.97); }
|
||||||
|
.dropdown-panel, #map-nocoords-panel, .decor-menu { scrollbar-width: thin; scrollbar-color: var(--border-strong) transparent; }
|
||||||
|
.dropdown-panel::-webkit-scrollbar, #map-nocoords-panel::-webkit-scrollbar, .decor-menu::-webkit-scrollbar { width: 9px; }
|
||||||
|
.dropdown-panel::-webkit-scrollbar-thumb, #map-nocoords-panel::-webkit-scrollbar-thumb, .decor-menu::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--border-strong); border-radius: 999px; border: 2px solid var(--bg);
|
||||||
|
}
|
||||||
|
.vis-time-axis .vis-text, #tooltip, .map-tip, .bl, input[type=date] { font-variant-numeric: tabular-nums; }
|
||||||
|
|||||||
@ -274,7 +274,7 @@ export function createTimelineView(el: TimelineElements, appearance: Appearance)
|
|||||||
groupsDS.clear();
|
groupsDS.clear();
|
||||||
itemsDS.clear();
|
itemsDS.clear();
|
||||||
el.timelineEl.style.display = 'none';
|
el.timelineEl.style.display = 'none';
|
||||||
el.empty.style.display = 'block';
|
el.empty.style.display = 'flex';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
el.timelineEl.style.display = '';
|
el.timelineEl.style.display = '';
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user