refactor(ui): slim content-height filter dock + searchable plate combobox

Right dock was a full-height 270px panel with a tall 6-row plate multi-select
and lots of dead space. Now a slim (~212px) content-height card: plate becomes a
searchable combobox with removable chips (hidden <select> kept as source of truth
so all filter logic is unchanged), cost-centre/city/time single-line, custom
dates side-by-side and only when needed. Better visual balance, more map.
Verified: dock 212x348 (was 270xfull), combobox search/pick/chip/remove all
drive the hidden select + KPIs; no overflow; no errors.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
kianiadee 2026-06-07 00:21:16 +03:00
parent 48631b6a38
commit 169fc21f36
2 changed files with 99 additions and 19 deletions

View file

@ -41,9 +41,13 @@ trips** into one view for the Fireside Communications / Tracksolid fleet.
(vehicle / cost centre / city) plus the **first trip** and **last trip** (vehicle / cost centre / city) plus the **first trip** and **last trip**
each with its **reverse-geocoded location and timestamp** — alongside the KPI each with its **reverse-geocoded location and timestamp** — alongside the KPI
totals (trips, km, driving/idle hours, vehicles, drivers, date range). totals (trips, km, driving/idle hours, vehicles, drivers, date range).
- **Fixed chrome (no floating panels).** Filters are a **fixed dock on the right**; - **Fixed chrome (no floating panels).** Filters are a **slim, content-height dock
in trips view the trips are **cards in a fixed bottom bar** (horizontally on the right**; in trips view the trips are **cards in a fixed bottom bar**
scrollable) that mirrors the top bar. Click a card to fit + animate that route. (horizontally scrollable) that mirrors the top bar. Click a card to fit +
animate that route.
- **Plate picker is a searchable combobox** — type to filter, click to add a
removable chip (multi-select), instead of a tall scrolling list. Cost centre /
city / time stay single-line; date pickers appear only for a custom range.
Live: <https://fleetnow.rahamafresh.com> Live: <https://fleetnow.rahamafresh.com>

View file

@ -128,7 +128,7 @@
grid-row: 3; min-height: 0; grid-row: 3; min-height: 0;
display: grid; display: grid;
/* minmax(0,…) so the horizontal trip-card scroller can't blow out the track */ /* minmax(0,…) so the horizontal trip-card scroller can't blow out the track */
grid-template-columns: minmax(0, 1fr) 270px; /* map | fixed filter dock */ grid-template-columns: minmax(0, 1fr) 224px; /* map | slim filter dock */
grid-template-rows: minmax(0, 1fr) auto; /* map | fixed trips bar */ grid-template-rows: minmax(0, 1fr) auto; /* map | fixed trips bar */
} }
#map { grid-column: 1; grid-row: 1; position: relative; min-height: 0; } #map { grid-column: 1; grid-row: 1; position: relative; min-height: 0; }
@ -138,32 +138,58 @@
pointer-events: none; z-index: 1; pointer-events: none; z-index: 1;
} }
/* Fixed right filter dock (full body height) */ /* Slim, content-height filter card — docked in the right column (top-aligned,
so no dead space below the button), part of the layout (won't drift). */
.filters { .filters {
grid-column: 2; grid-row: 1 / span 2; grid-column: 2; grid-row: 1 / span 2; align-self: start;
background: var(--panel); border-left: 1px solid var(--border); margin: 12px 12px 0 0; max-height: calc(100% - 24px); overflow-y: auto;
padding: 16px 16px 18px; overflow-y: auto; background: var(--panel); border: 1px solid var(--border); border-radius: 10px;
padding: 13px 13px 14px;
} }
.filters h3 { .filters h3 {
font-size: 11px; text-transform: uppercase; letter-spacing: .6px; font-size: 11px; text-transform: uppercase; letter-spacing: .6px;
color: var(--muted); margin: 0 0 14px; font-weight: 600; color: var(--muted); margin: 0 0 12px; font-weight: 600;
} }
.field { display: flex; flex-direction: column; margin-bottom: 13px; } .field { display: flex; flex-direction: column; margin-bottom: 11px; }
.field label { .field label {
font-size: 10.5px; text-transform: uppercase; letter-spacing: .5px; font-size: 10px; text-transform: uppercase; letter-spacing: .5px;
color: var(--muted); margin-bottom: 4px; color: var(--muted); margin-bottom: 4px;
} }
select, input[type=date] { select, input[type=date] {
padding: 8px 10px; background: var(--bg); color: var(--text); padding: 7px 9px; background: var(--bg); color: var(--text);
border: 1px solid var(--border); border-radius: 6px; font: 13px system-ui; width: 100%; border: 1px solid var(--border); border-radius: 6px; font: 13px system-ui; width: 100%;
} }
select:focus, input:focus { outline: 2px solid var(--accent); outline-offset: -1px; } select:focus, input:focus { outline: 2px solid var(--accent); outline-offset: -1px; }
select[multiple] { min-height: 120px; max-height: 200px; padding: 5px; }
select[multiple] option { padding: 4px 6px; border-radius: 3px; }
select[multiple] option:checked { background: var(--accent); color: #1a1009; }
.hint { font-size: 9.5px; color: var(--muted); margin-top: 3px; line-height: 1.3; } .hint { font-size: 9.5px; color: var(--muted); margin-top: 3px; line-height: 1.3; }
.custom { display: none; } .custom { display: none; }
.custom.show { display: block; } .custom.show { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
.custom .field { margin-bottom: 0; }
.custom input[type=date] { padding: 6px 6px; font-size: 11.5px; }
/* Searchable plate combobox + chips (replaces the tall multi-select) */
.plate-box { position: relative; }
.plate-chips { display: flex; flex-wrap: wrap; gap: 4px; margin-bottom: 5px; }
.plate-chips:empty { display: none; }
.plate-chip {
display: inline-flex; align-items: center; gap: 5px;
background: var(--accent); color: #1a1009; font: 600 11px system-ui;
padding: 2px 4px 2px 8px; border-radius: 999px;
}
.plate-chip button { background: none; border: 0; color: #1a1009; cursor: pointer; font-size: 13px; line-height: 1; padding: 0 2px; opacity: .75; }
.plate-chip button:hover { opacity: 1; }
.plate-search { width: 100%; padding: 7px 9px; background: var(--bg); color: var(--text); border: 1px solid var(--border); border-radius: 6px; font: 13px system-ui; }
.plate-search:focus { outline: 2px solid var(--accent); outline-offset: -1px; }
.plate-results {
display: none; position: absolute; z-index: 20; left: 0; right: 0; top: 100%; margin-top: 3px;
max-height: 200px; overflow-y: auto; background: var(--panel-2);
border: 1px solid var(--border); border-radius: 8px; box-shadow: 0 8px 22px rgba(0,0,0,.5);
}
.plate-results.show { display: block; }
.plate-opt { padding: 7px 10px; font-size: 12.5px; cursor: pointer; color: var(--text); }
.plate-opt:hover, .plate-opt.hi { background: rgba(232,149,74,.18); }
.plate-opt.sel { color: var(--accent); }
.plate-opt .pd { color: var(--muted); font-size: 11px; }
.plate-none { padding: 8px 10px; color: var(--muted); font-size: 11.5px; }
.btn { .btn {
width: 100%; padding: 10px; margin-top: 6px; background: var(--accent); width: 100%; padding: 10px; margin-top: 6px; background: var(--accent);
color: #1a1009; border: 0; border-radius: 6px; font: 600 13px system-ui; cursor: pointer; color: #1a1009; border: 0; border-radius: 6px; font: 600 13px system-ui; cursor: pointer;
@ -367,8 +393,14 @@
<aside class="filters" id="filters"> <aside class="filters" id="filters">
<h3>Filters</h3> <h3>Filters</h3>
<div class="field"> <div class="field">
<label for="f-vehicle">Number plate <span class="hint" style="text-transform:none">— ⌘/Ctrl-click for more</span></label> <label for="plate-search">Number plate</label>
<select id="f-vehicle" multiple size="6"></select> <div class="plate-box">
<div class="plate-chips" id="plate-chips"></div>
<input type="text" id="plate-search" class="plate-search" placeholder="Search plate…" autocomplete="off">
<div class="plate-results" id="plate-results"></div>
</div>
<!-- Source of truth for all filter logic; UI above drives it. -->
<select id="f-vehicle" multiple hidden></select>
</div> </div>
<div class="field"> <div class="field">
<label for="f-cc">Cost centre</label> <label for="f-cc">Cost centre</label>
@ -972,11 +1004,55 @@ function applyVehicleAutoFilter() {
const metas = selected.map(p => VEHICLE_META.get(p)).filter(Boolean); const metas = selected.map(p => VEHICLE_META.get(p)).filter(Boolean);
setSelectValue('f-cc', collapse(metas.map(m => m.cost_centre)) ?? ''); setSelectValue('f-cc', collapse(metas.map(m => m.cost_centre)) ?? '');
setSelectValue('f-city', collapse(metas.map(m => m.assigned_city)) ?? ''); setSelectValue('f-city', collapse(metas.map(m => m.assigned_city)) ?? '');
renderPlateChips();
applyLiveFilters(); // reflect the plate selection on the live map immediately applyLiveFilters(); // reflect the plate selection on the live map immediately
} }
function collapse(values) { if (!values.length) return null; const f = values[0]; return values.every(v => v === f) ? f : null; } function collapse(values) { if (!values.length) return null; const f = values[0]; return values.every(v => v === f) ? f : null; }
function setSelectValue(id, value) { const el = document.getElementById(id); const opt = Array.from(el.options).find(o => o.value === value); el.value = opt ? value : ''; } function setSelectValue(id, value) { const el = document.getElementById(id); const opt = Array.from(el.options).find(o => o.value === value); el.value = opt ? value : ''; }
// ── Searchable plate combobox — drives the hidden #f-vehicle multi-select ────
const _plateSearch = document.getElementById('plate-search');
const _plateResults = document.getElementById('plate-results');
const _plateChips = document.getElementById('plate-chips');
function _plateOpts() { return Array.from(document.getElementById('f-vehicle').options); }
function _fireVehChange() { document.getElementById('f-vehicle').dispatchEvent(new Event('change')); }
function renderPlateChips() {
_plateChips.innerHTML = '';
_plateOpts().filter(o => o.selected).forEach(o => {
const chip = document.createElement('span');
chip.className = 'plate-chip';
chip.innerHTML = `${escapeHtml(o.value)} <button type="button" aria-label="remove">&times;</button>`;
chip.querySelector('button').addEventListener('click', () => { o.selected = false; _fireVehChange(); });
_plateChips.appendChild(chip);
});
}
function renderPlateResults(q) {
q = (q || '').trim().toLowerCase();
const matches = _plateOpts().filter(o => o.value && (!q || o.textContent.toLowerCase().includes(q))).slice(0, 60);
_plateResults.innerHTML = '';
if (!matches.length) { _plateResults.innerHTML = '<div class="plate-none">No matching plate</div>'; _plateResults.classList.add('show'); return; }
matches.forEach(o => {
const div = document.createElement('div');
div.className = 'plate-opt' + (o.selected ? ' sel' : '');
const [plate, driver] = o.textContent.split(' — ');
div.innerHTML = driver ? `${escapeHtml(plate)} <span class="pd">— ${escapeHtml(driver)}</span>` : escapeHtml(plate);
div.addEventListener('mousedown', e => { // mousedown: fires before the input's blur
e.preventDefault();
o.selected = !o.selected;
_fireVehChange();
_plateSearch.value = '';
renderPlateResults('');
_plateSearch.focus();
});
_plateResults.appendChild(div);
});
_plateResults.classList.add('show');
}
_plateSearch.addEventListener('focus', () => renderPlateResults(_plateSearch.value));
_plateSearch.addEventListener('input', () => renderPlateResults(_plateSearch.value));
_plateSearch.addEventListener('blur', () => setTimeout(() => _plateResults.classList.remove('show'), 150));
document.addEventListener('click', e => { if (!e.target.closest('.plate-box')) _plateResults.classList.remove('show'); });
// Scale the live markers with the zoom level so they don't bloat at country // Scale the live markers with the zoom level so they don't bloat at country
// zoom or vanish when zoomed in. Linear from z5 → z14. // zoom or vanish when zoomed in. Linear from z5 → z14.
function updateVehScale() { function updateVehScale() {