ui: toasts, icon segmented mode switch, steppers, date-range picker (v0.2.13)
Second modernization batch (reference: easystudy ui_elements): - Toast/snackbar notifications (dark, green check) for actions — copy link. - Mode switch: icons (horizontal bars / pin / columns) + a sliding active indicator; fixed width so the bold active label never resizes the block. - Appearance number fields became steppers: an editable field with centred −/+ buttons, ~20% narrower. - Date filter: the two native date inputs are replaced by a custom range picker — one field "Период (даты)" + a calendar popup with month nav and range selection (circle endpoints, light band between). The native inputs stay hidden as the source of truth so all filter/URL/chip wiring is unchanged. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
f5166b888a
commit
e9eee60dab
@ -47,9 +47,10 @@
|
||||
<!-- Group 1b: view mode switch -->
|
||||
<div class="hgroup hgroup-modes">
|
||||
<div class="mode-switch">
|
||||
<button type="button" class="mode-btn active" id="mode-timeline">График</button>
|
||||
<button type="button" class="mode-btn" id="mode-map">Карта</button>
|
||||
<button type="button" class="mode-btn" id="mode-split">Карта и график</button>
|
||||
<div class="mode-ind" aria-hidden="true"></div>
|
||||
<button type="button" class="mode-btn active" id="mode-timeline"><svg class="mode-ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><line x1="4" y1="7" x2="16" y2="7"/><line x1="4" y1="12" x2="20" y2="12"/><line x1="4" y1="17" x2="11" y2="17"/></svg>График</button>
|
||||
<button type="button" class="mode-btn" id="mode-map"><svg class="mode-ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" 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>Карта</button>
|
||||
<button type="button" class="mode-btn" id="mode-split"><svg class="mode-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="7" height="16" rx="1"/><rect x="14" y="4" width="7" height="16" rx="1"/></svg>Карта и график</button>
|
||||
</div>
|
||||
<button type="button" class="theme-toggle" id="theme-toggle" title="Переключить тему" aria-label="Переключить тему"></button>
|
||||
</div>
|
||||
@ -96,13 +97,18 @@
|
||||
<div class="dropdown-panel" id="manager-panel"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="date-start">Дата начала (от)</label>
|
||||
<input type="date" id="date-start" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="date-end">Дата окончания (до)</label>
|
||||
<input type="date" id="date-end" />
|
||||
<div class="field field-dates">
|
||||
<label>Период (даты)</label>
|
||||
<div class="daterange" id="daterange">
|
||||
<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>
|
||||
<span class="dr-text" id="dr-text">Период</span>
|
||||
<button type="button" class="dr-clear" id="dr-clear" aria-label="Очистить" style="display:none">×</button>
|
||||
</div>
|
||||
<div class="dr-cal" id="dr-cal" hidden></div>
|
||||
<input type="date" id="date-start" class="dr-native" />
|
||||
<input type="date" id="date-end" class="dr-native" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="field manager-only">
|
||||
<label>Статус</label>
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "mapdash-frontend",
|
||||
"private": true,
|
||||
"version": "0.2.12",
|
||||
"version": "0.2.13",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@ -98,21 +98,53 @@ export function applyCssVars(a: Appearance): void {
|
||||
// ---- settings menu UI ----------------------------------------------------
|
||||
|
||||
function numberRow(labelText: string, value: number, min: number, max: number, onInput: (v: number) => void): HTMLElement {
|
||||
const row = document.createElement('label');
|
||||
// A stepper: −/+ buttons around an editable number field (type a value directly).
|
||||
const row = document.createElement('div');
|
||||
row.className = 'decor-row';
|
||||
const span = document.createElement('span');
|
||||
span.textContent = labelText;
|
||||
|
||||
const stepper = document.createElement('div');
|
||||
stepper.className = 'decor-stepper';
|
||||
const input = document.createElement('input');
|
||||
input.type = 'number';
|
||||
input.className = 'ds-input';
|
||||
input.min = String(min);
|
||||
input.max = String(max);
|
||||
input.value = String(value);
|
||||
const dec = document.createElement('button');
|
||||
dec.type = 'button';
|
||||
dec.className = 'ds-btn';
|
||||
dec.textContent = '−';
|
||||
dec.setAttribute('aria-label', 'Уменьшить');
|
||||
const inc = document.createElement('button');
|
||||
inc.type = 'button';
|
||||
inc.className = 'ds-btn';
|
||||
inc.textContent = '+';
|
||||
inc.setAttribute('aria-label', 'Увеличить');
|
||||
|
||||
const clamp = (v: number): number => Math.max(min, Math.min(max, v));
|
||||
// While typing: apply the clamped value live but leave the field as-is so
|
||||
// multi-digit input isn't fought (e.g. typing "30" when min is 8).
|
||||
input.addEventListener('input', () => {
|
||||
const v = parseInt(input.value, 10);
|
||||
if (!Number.isNaN(v)) onInput(Math.max(min, Math.min(max, v)));
|
||||
if (!Number.isNaN(v)) onInput(clamp(v));
|
||||
});
|
||||
// On blur/Enter and on the buttons: normalise the field to the clamped value.
|
||||
const commit = (v: number): void => {
|
||||
const c = clamp(Number.isNaN(v) ? value : v);
|
||||
input.value = String(c);
|
||||
onInput(c);
|
||||
};
|
||||
input.addEventListener('change', () => commit(parseInt(input.value, 10)));
|
||||
dec.addEventListener('click', () => commit((parseInt(input.value, 10) || min) - 1));
|
||||
inc.addEventListener('click', () => commit((parseInt(input.value, 10) || min) + 1));
|
||||
|
||||
stepper.appendChild(dec);
|
||||
stepper.appendChild(input);
|
||||
stepper.appendChild(inc);
|
||||
row.appendChild(span);
|
||||
row.appendChild(input);
|
||||
row.appendChild(stepper);
|
||||
return row;
|
||||
}
|
||||
|
||||
|
||||
144
frontend/src/datepicker.ts
Normal file
144
frontend/src/datepicker.ts
Normal file
@ -0,0 +1,144 @@
|
||||
// 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
|
||||
// keeps reading/writing them — while this renders the field + calendar popup and
|
||||
// dispatches `change` on them so the existing reload wiring fires.
|
||||
|
||||
const MONTHS = ['Январь', 'Февраль', 'Март', 'Апрель', 'Май', 'Июнь', 'Июль', 'Август', 'Сентябрь', 'Октябрь', 'Ноябрь', 'Декабрь'];
|
||||
const WEEKDAYS = ['пн', 'вт', 'ср', 'чт', 'пт', 'сб', 'вс'];
|
||||
|
||||
function parseISO(iso: string): Date | null {
|
||||
return iso ? new Date(iso + 'T00:00:00') : null;
|
||||
}
|
||||
function toISO(d: Date): string {
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||||
}
|
||||
function fmt(iso: string): string {
|
||||
const p = iso.split('-');
|
||||
return p.length === 3 ? `${p[2]}.${p[1]}.${p[0]}` : iso;
|
||||
}
|
||||
|
||||
export interface DateRange {
|
||||
/** Refresh the field text (+ open calendar) from the hidden inputs. */
|
||||
sync(): void;
|
||||
}
|
||||
|
||||
export function createDateRange(): DateRange {
|
||||
const startInput = document.getElementById('date-start') as HTMLInputElement;
|
||||
const endInput = document.getElementById('date-end') as HTMLInputElement;
|
||||
const field = document.getElementById('dr-field') as HTMLElement;
|
||||
const text = document.getElementById('dr-text') as HTMLElement;
|
||||
const clearBtn = document.getElementById('dr-clear') as HTMLElement;
|
||||
const cal = document.getElementById('dr-cal') as HTMLElement;
|
||||
|
||||
let viewY = new Date().getFullYear();
|
||||
let viewM = new Date().getMonth();
|
||||
|
||||
function updateText(): void {
|
||||
const s = startInput.value;
|
||||
const e = endInput.value;
|
||||
if (s && e) text.textContent = `${fmt(s)} — ${fmt(e)}`;
|
||||
else if (s) text.textContent = `с ${fmt(s)}`;
|
||||
else if (e) text.textContent = `по ${fmt(e)}`;
|
||||
else text.textContent = 'Период';
|
||||
clearBtn.style.display = s || e ? '' : 'none';
|
||||
field.classList.toggle('has-value', !!(s || e));
|
||||
}
|
||||
|
||||
function render(): void {
|
||||
const s = parseISO(startInput.value);
|
||||
const e = parseISO(endInput.value);
|
||||
const parts: string[] = [
|
||||
`<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>`,
|
||||
'<div class="dr-grid">',
|
||||
];
|
||||
for (const w of WEEKDAYS) parts.push(`<span class="dr-wd">${w}</span>`);
|
||||
const lead = (new Date(viewY, viewM, 1).getDay() + 6) % 7; // Monday-first
|
||||
const days = new Date(viewY, viewM + 1, 0).getDate();
|
||||
for (let i = 0; i < lead; i++) parts.push('<span class="dr-blank"></span>');
|
||||
for (let d = 1; d <= days; d++) {
|
||||
const cur = new Date(viewY, viewM, d);
|
||||
const iso = toISO(cur);
|
||||
let cls = 'dr-day';
|
||||
if (iso === startInput.value) cls += ' s';
|
||||
if (iso === endInput.value) cls += ' e';
|
||||
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('</div>');
|
||||
cal.innerHTML = parts.join('');
|
||||
}
|
||||
|
||||
function commit(): void {
|
||||
startInput.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
endInput.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
updateText();
|
||||
}
|
||||
|
||||
function pick(iso: string): void {
|
||||
const s = startInput.value;
|
||||
const e = endInput.value;
|
||||
if (!s || (s && e)) {
|
||||
// start a fresh range
|
||||
startInput.value = iso;
|
||||
endInput.value = '';
|
||||
} else if (iso >= s) {
|
||||
endInput.value = iso; // complete the range
|
||||
} else {
|
||||
startInput.value = iso; // clicked before the start — move the start
|
||||
}
|
||||
commit();
|
||||
render();
|
||||
}
|
||||
|
||||
function open(): void {
|
||||
const base = parseISO(startInput.value) || new Date();
|
||||
viewY = base.getFullYear();
|
||||
viewM = base.getMonth();
|
||||
render();
|
||||
cal.hidden = false;
|
||||
}
|
||||
function close(): void {
|
||||
cal.hidden = true;
|
||||
}
|
||||
|
||||
field.addEventListener('click', (ev) => {
|
||||
if ((ev.target as HTMLElement).closest('.dr-clear')) return;
|
||||
if (cal.hidden) open();
|
||||
else close();
|
||||
});
|
||||
field.addEventListener('keydown', (ev) => {
|
||||
if (ev.key === 'Enter' || ev.key === ' ') { ev.preventDefault(); if (cal.hidden) open(); else close(); }
|
||||
});
|
||||
clearBtn.addEventListener('click', (ev) => {
|
||||
ev.stopPropagation();
|
||||
startInput.value = '';
|
||||
endInput.value = '';
|
||||
commit();
|
||||
close();
|
||||
});
|
||||
cal.addEventListener('click', (ev) => {
|
||||
const target = ev.target as HTMLElement;
|
||||
const nav = target.closest<HTMLElement>('.dr-nav');
|
||||
if (nav) {
|
||||
viewM += Number(nav.dataset.nav);
|
||||
if (viewM < 0) { viewM = 11; viewY--; } else if (viewM > 11) { viewM = 0; viewY++; }
|
||||
render();
|
||||
return;
|
||||
}
|
||||
const day = target.closest<HTMLElement>('.dr-day');
|
||||
if (day && day.dataset.iso) pick(day.dataset.iso);
|
||||
});
|
||||
document.addEventListener('click', (ev) => {
|
||||
if (!(ev.target as HTMLElement).closest('#daterange')) close();
|
||||
});
|
||||
|
||||
updateText();
|
||||
return {
|
||||
sync(): void {
|
||||
updateText();
|
||||
if (!cal.hidden) render();
|
||||
},
|
||||
};
|
||||
}
|
||||
@ -14,6 +14,7 @@ import 'vis-timeline/styles/vis-timeline-graph2d.min.css';
|
||||
import { api } from './api';
|
||||
import type { Board, Booking, Filters } from './types';
|
||||
import { createDropdown } from './dropdown';
|
||||
import { createDateRange } from './datepicker';
|
||||
import { createTimelineView, zoomSteps, type RenderFlags } from './timeline';
|
||||
import { loadAppearance, applyCssVars, buildAppearanceMenu, statusLabel } from './appearance';
|
||||
import { createMapView } from './map';
|
||||
@ -113,6 +114,7 @@ const brandDropdown = createDropdown('brand');
|
||||
const dimensionDropdown = createDropdown('dimension');
|
||||
const managerDropdown = createDropdown('manager');
|
||||
const statusDropdown = createDropdown('status', statusLabel);
|
||||
const dateRange = createDateRange();
|
||||
const view = createTimelineView(
|
||||
{
|
||||
timelineEl: el.timelineEl,
|
||||
@ -230,6 +232,32 @@ const fmtD = (iso: string): string => {
|
||||
return p.length === 3 ? `${p[2]}.${p[1]}.${p[0]}` : iso;
|
||||
};
|
||||
|
||||
// ---- toast notifications (transient action feedback) ----
|
||||
let toastHost: HTMLElement | null = null;
|
||||
function showToast(message: string): void {
|
||||
if (!toastHost) {
|
||||
toastHost = document.createElement('div');
|
||||
toastHost.className = 'toasts';
|
||||
document.body.appendChild(toastHost);
|
||||
}
|
||||
const t = document.createElement('div');
|
||||
t.className = 'toast';
|
||||
const ico = document.createElement('span');
|
||||
ico.className = 'toast-ico';
|
||||
ico.innerHTML = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>';
|
||||
const txt = document.createElement('span');
|
||||
txt.textContent = message;
|
||||
t.appendChild(ico);
|
||||
t.appendChild(txt);
|
||||
toastHost.appendChild(t);
|
||||
void t.offsetWidth; // reflow so the enter transition runs (rAF is paused in bg tabs)
|
||||
t.classList.add('show');
|
||||
window.setTimeout(() => {
|
||||
t.classList.remove('show');
|
||||
window.setTimeout(() => t.remove(), 250);
|
||||
}, 2500);
|
||||
}
|
||||
|
||||
// ---- shareable filter links: current filters <-> URL query ----
|
||||
function filtersToUrl(): void {
|
||||
const f = currentFilters();
|
||||
@ -288,11 +316,8 @@ function renderChips(): void {
|
||||
share.addEventListener('click', () => {
|
||||
navigator.clipboard
|
||||
?.writeText(location.href)
|
||||
.then(() => {
|
||||
share.textContent = 'Ссылка скопирована';
|
||||
window.setTimeout(() => { share.textContent = 'Скопировать ссылку'; }, 1600);
|
||||
})
|
||||
.catch(() => { /* clipboard unavailable */ });
|
||||
.then(() => showToast('Ссылка скопирована'))
|
||||
.catch(() => showToast('Не удалось скопировать'));
|
||||
});
|
||||
el.filterChips.appendChild(share);
|
||||
const divider = document.createElement('span');
|
||||
@ -341,6 +366,7 @@ async function loadData(fit: boolean): Promise<void> {
|
||||
el.status.innerHTML = `<span class="st-l">Поверхности</span><span class="st-v">${boards.length}</span>`;
|
||||
rebuildDecorMenu(); // status list may have changed
|
||||
renderChips();
|
||||
dateRange.sync(); // keep the date-range field in step with reset / chip-remove / URL
|
||||
filtersToUrl();
|
||||
// Keep the map in sync when it is visible (facets filter it; dates do not).
|
||||
if (mapVisible()) void refreshMap();
|
||||
|
||||
@ -121,6 +121,45 @@ header {
|
||||
@media (max-width: 760px) { .hgroup-fields { grid-template-columns: repeat(2, minmax(120px, 1fr)); } }
|
||||
/* Fields group: top-align so the date input sits UNDER Город/Бренд. */
|
||||
.hgroup-fields { align-items: flex-start; }
|
||||
|
||||
/* ---- date-range field + calendar popup ---- */
|
||||
.field-dates { grid-column: span 2; }
|
||||
.dr-native { display: none; }
|
||||
.daterange { position: relative; }
|
||||
.dr-field {
|
||||
display: flex; align-items: center; gap: 8px; width: 100%; box-sizing: border-box;
|
||||
height: 36px; padding: 0 10px; cursor: pointer; font-size: 13px; color: var(--text-muted);
|
||||
border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--bg);
|
||||
transition: border-color var(--transition);
|
||||
}
|
||||
.dr-field:hover { border-color: var(--border-strong); }
|
||||
.dr-field.has-value { color: var(--text); }
|
||||
.dr-ico { width: 16px; height: 16px; flex: 0 0 auto; color: var(--accent); }
|
||||
.dr-text { flex: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-variant-numeric: tabular-nums; }
|
||||
.dr-clear { border: none; background: none; color: var(--text-muted); cursor: pointer; font-size: 16px; line-height: 1; padding: 0 2px; flex: 0 0 auto; }
|
||||
.dr-clear:hover { color: var(--text); }
|
||||
.dr-cal {
|
||||
position: absolute; top: calc(100% + 4px); left: 0; z-index: 40; width: 252px;
|
||||
background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius);
|
||||
box-shadow: var(--shadow-md); padding: 10px; font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.dr-cal[hidden] { display: none; }
|
||||
.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); }
|
||||
.dr-nav:hover { background: var(--surface); color: var(--text); }
|
||||
.dr-grid { display: grid; grid-template-columns: repeat(7, 1fr); }
|
||||
.dr-wd { text-align: center; font-size: 11px; color: var(--text-muted); padding-bottom: 4px; }
|
||||
.dr-day { position: relative; border: none; background: none; padding: 2px 0; cursor: pointer; display: flex; align-items: center; justify-content: center; height: 30px; }
|
||||
.dr-day::before { content: ""; position: absolute; inset: 0; z-index: 0; }
|
||||
.dr-day.rng::before { background: #eef0fe; }
|
||||
.dr-day.s::before { background: linear-gradient(to right, transparent 50%, #eef0fe 50%); }
|
||||
.dr-day.e::before { background: linear-gradient(to left, transparent 50%, #eef0fe 50%); }
|
||||
.dr-day.s.e::before { background: transparent; }
|
||||
.dr-num { position: relative; z-index: 1; display: inline-flex; align-items: center; justify-content: center; width: 26px; height: 26px; border-radius: 50%; font-size: 12px; color: var(--text); }
|
||||
.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:hover .dr-num, .dr-day.e:hover .dr-num { background: var(--accent); }
|
||||
.fcol { display: flex; flex-direction: column; gap: 8px; }
|
||||
|
||||
input[type=date],
|
||||
@ -328,6 +367,24 @@ input::placeholder { color: var(--text-muted); }
|
||||
width: 84px; padding: 5px 6px; border: 1px solid var(--border); border-radius: var(--radius-sm);
|
||||
font-size: 13px; background: var(--bg); color: var(--text);
|
||||
}
|
||||
/* Stepper: −/+ buttons around an editable number field. */
|
||||
.decor-stepper { display: inline-flex; align-items: center; border: 1px solid var(--border); border-radius: var(--radius-sm); overflow: hidden; }
|
||||
.decor-stepper .ds-btn {
|
||||
display: flex; align-items: center; justify-content: center; padding: 0;
|
||||
width: 22px; height: 28px; border: none; background: var(--surface);
|
||||
color: var(--accent); font-size: 16px; line-height: 1; cursor: pointer;
|
||||
transition: background var(--transition);
|
||||
}
|
||||
.decor-stepper .ds-btn:hover { background: var(--surface-2); }
|
||||
.decor-stepper .ds-input {
|
||||
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-radius: 0; font-size: 13px; background: var(--bg); color: var(--text);
|
||||
font-variant-numeric: tabular-nums; -moz-appearance: textfield;
|
||||
}
|
||||
.decor-stepper .ds-input::-webkit-outer-spin-button,
|
||||
.decor-stepper .ds-input::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; }
|
||||
.decor-stepper .ds-input:focus { outline: none; }
|
||||
.decor-row input[type=color] {
|
||||
width: 42px; height: 28px; padding: 0; border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm); background: var(--bg); cursor: pointer;
|
||||
@ -351,18 +408,42 @@ input::placeholder { color: var(--text-muted); }
|
||||
/* ---- view modes: segmented control ---- */
|
||||
.mode-switch {
|
||||
display: inline-flex; flex-direction: column; gap: 5px;
|
||||
background: var(--surface-2); border: none;
|
||||
background: var(--surface-2); border: none; position: relative;
|
||||
border-radius: var(--radius); padding: 6px;
|
||||
height: 100%; /* fill the header row so the bottom lines up with the date fields */
|
||||
width: 160px; /* fixed so the bold active label doesn't resize the block */
|
||||
}
|
||||
/* Sliding active-mode indicator (JS sets top/height to the active button). */
|
||||
.mode-ind {
|
||||
position: absolute; left: 6px; right: 6px; top: 6px; height: 0; z-index: 0;
|
||||
background: var(--bg); border-radius: var(--radius-sm); box-shadow: var(--shadow-sm);
|
||||
transition: top .2s var(--ease-3), height .2s var(--ease-3); pointer-events: none;
|
||||
}
|
||||
.mode-btn {
|
||||
position: relative; z-index: 1;
|
||||
border: none; background: transparent; color: var(--text-muted); cursor: pointer;
|
||||
padding: 6px 13px; font-size: 13px; white-space: nowrap; text-align: left; width: 100%;
|
||||
border-radius: var(--radius-sm); transition: background var(--transition), color var(--transition);
|
||||
flex: 1; display: flex; align-items: center; /* even heights, vertically centred */
|
||||
padding: 6px 12px; font-size: 13px; white-space: nowrap; text-align: left; width: 100%;
|
||||
border-radius: var(--radius-sm); transition: color var(--transition);
|
||||
flex: 1; display: flex; align-items: center; gap: 8px; /* icon + label */
|
||||
}
|
||||
.mode-ico { width: 16px; height: 16px; flex: 0 0 auto; }
|
||||
.mode-btn:hover { color: var(--text); }
|
||||
.mode-btn.active { background: var(--bg); color: var(--accent); font-weight: 600; box-shadow: var(--shadow-sm); }
|
||||
.mode-btn.active { color: var(--accent); font-weight: 600; }
|
||||
|
||||
/* ---- toast notifications (bottom-centre, transient) ---- */
|
||||
.toasts {
|
||||
position: fixed; left: 50%; bottom: 26px; transform: translateX(-50%); z-index: 200;
|
||||
display: flex; flex-direction: column; gap: 8px; align-items: center; pointer-events: none;
|
||||
}
|
||||
.toast {
|
||||
display: flex; align-items: center; gap: 9px;
|
||||
background: var(--tip-bg); color: var(--tip-text); font-size: 13px;
|
||||
padding: 9px 15px; border-radius: 9px; box-shadow: var(--shadow-md);
|
||||
opacity: 0; transform: translateY(8px);
|
||||
transition: opacity .22s var(--ease-3), transform .22s var(--ease-3);
|
||||
}
|
||||
.toast.show { opacity: 1; transform: translateY(0); }
|
||||
.toast-ico { color: #37b24d; display: inline-flex; }
|
||||
|
||||
/* Theme toggle */
|
||||
.theme-toggle {
|
||||
|
||||
@ -37,13 +37,23 @@ export function createViewModes(els: ViewModeElements, cb: ViewModeCallbacks): V
|
||||
els.view.style.setProperty('--split-left', savedSplit + '%');
|
||||
}
|
||||
|
||||
function positionIndicator(): void {
|
||||
const active = mode === 'timeline' ? els.btnTimeline : mode === 'map' ? els.btnMap : els.btnSplit;
|
||||
const ind = els.btnTimeline.parentElement?.querySelector<HTMLElement>('.mode-ind');
|
||||
if (ind && active.offsetHeight) {
|
||||
ind.style.top = active.offsetTop + 'px';
|
||||
ind.style.height = active.offsetHeight + 'px';
|
||||
}
|
||||
}
|
||||
function apply(): void {
|
||||
els.view.classList.remove('mode-timeline', 'mode-map', 'mode-split');
|
||||
els.view.classList.add('mode-' + mode);
|
||||
els.btnTimeline.classList.toggle('active', mode === 'timeline');
|
||||
els.btnMap.classList.toggle('active', mode === 'map');
|
||||
els.btnSplit.classList.toggle('active', mode === 'split');
|
||||
positionIndicator();
|
||||
}
|
||||
window.addEventListener('resize', positionIndicator);
|
||||
|
||||
function setMode(next: ViewMode): void {
|
||||
mode = next;
|
||||
@ -95,6 +105,7 @@ export function createViewModes(els: ViewModeElements, cb: ViewModeCallbacks): V
|
||||
const saved = localStorage.getItem(LS_MODE) as ViewMode | null;
|
||||
mode = saved === 'map' || saved === 'split' ? saved : 'timeline';
|
||||
apply();
|
||||
requestAnimationFrame(positionIndicator); // header is laid out by the next frame
|
||||
|
||||
return {
|
||||
getMode: () => mode,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user