diff --git a/frontend/package.json b/frontend/package.json
index c0e7f1d..109009f 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -1,7 +1,7 @@
{
"name": "mapdash-frontend",
"private": true,
- "version": "0.2.12",
+ "version": "0.2.13",
"type": "module",
"scripts": {
"dev": "vite",
diff --git a/frontend/src/appearance.ts b/frontend/src/appearance.ts
index a385015..597bf06 100644
--- a/frontend/src/appearance.ts
+++ b/frontend/src/appearance.ts
@@ -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;
}
diff --git a/frontend/src/datepicker.ts b/frontend/src/datepicker.ts
new file mode 100644
index 0000000..74bb989
--- /dev/null
+++ b/frontend/src/datepicker.ts
@@ -0,0 +1,144 @@
+// Custom date-range picker. The two native (#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[] = [
+ `
` +
+ `${MONTHS[viewM]} ${viewY}` +
+ `
`,
+ '
',
+ ];
+ for (const w of WEEKDAYS) parts.push(`${w}`);
+ 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('');
+ 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(``);
+ }
+ parts.push('