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:
parent
48631b6a38
commit
169fc21f36
2 changed files with 99 additions and 19 deletions
10
README.md
10
README.md
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
||||||
106
index.html
106
index.html
|
|
@ -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">×</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() {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue