+173
-87
@@ -1,17 +1,16 @@
|
||||
/* Autocomplete for the origin/destination fields, backed by /api/stops.
|
||||
Progressive enhancement: without JS the inputs stay plain free-text fields.
|
||||
A picked stop fills the sibling hidden <input> with its id; editing the text
|
||||
afterwards clears it again, so unmatched entries save as free text. */
|
||||
/* Autocomplete helpers for the "Neue Fahrt" form.
|
||||
- origin/destination: stop lookup from /api/stops, fills a hidden *_stop_id
|
||||
when the text matches a known Austrian stop (free text still allowed).
|
||||
- line: suggestions from /api/lines, scoped to the chosen origin/destination
|
||||
stops. Always free text; when exactly one line is possible and the field is
|
||||
untouched it is pre-filled.
|
||||
Progressive enhancement: without JS every field stays a plain input. */
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
var TYPE_LABEL = {
|
||||
bus: "Bus",
|
||||
tram: "Straßenbahn",
|
||||
subway: "U-Bahn",
|
||||
train: "Zug",
|
||||
bus_station: "Busbahnhof",
|
||||
other: "Haltestelle",
|
||||
bus: "Bus", tram: "Straßenbahn", subway: "U-Bahn", train: "Zug",
|
||||
bus_station: "Busbahnhof", other: "Haltestelle",
|
||||
};
|
||||
|
||||
function debounce(fn, ms) {
|
||||
@@ -23,12 +22,11 @@
|
||||
};
|
||||
}
|
||||
|
||||
function setup(input) {
|
||||
/* Shared dropdown: owns the list element, keyboard nav and open/close.
|
||||
opts: { fetchItems(query) -> Promise<item[]>,
|
||||
fillOption(item, el), onChoose(item), minChars, onInput() } */
|
||||
function attachCombo(input, opts) {
|
||||
var wrap = input.closest(".combo");
|
||||
var hidden = wrap.querySelector(input.dataset.stopTarget);
|
||||
var hint = wrap.querySelector("[data-stop-hint]");
|
||||
if (!hidden) return;
|
||||
|
||||
var list = document.createElement("div");
|
||||
list.className = "combo-list";
|
||||
list.hidden = true;
|
||||
@@ -36,109 +34,197 @@
|
||||
|
||||
var items = [];
|
||||
var active = -1;
|
||||
var lastQuery = "";
|
||||
|
||||
function clearMatch() {
|
||||
hidden.value = "";
|
||||
if (hint) { hint.textContent = ""; hint.classList.remove("matched"); }
|
||||
}
|
||||
|
||||
function markMatch(stop) {
|
||||
hidden.value = String(stop.id);
|
||||
if (hint) {
|
||||
hint.textContent =
|
||||
"✓ " + stop.name + (stop.municipality ? ", " + stop.municipality : "");
|
||||
hint.classList.add("matched");
|
||||
}
|
||||
}
|
||||
var lastQuery = null;
|
||||
var minChars = opts.minChars != null ? opts.minChars : 2;
|
||||
|
||||
function close() { list.hidden = true; active = -1; }
|
||||
|
||||
function render() {
|
||||
list.innerHTML = "";
|
||||
if (!items.length) {
|
||||
var empty = document.createElement("div");
|
||||
empty.className = "combo-empty";
|
||||
empty.textContent =
|
||||
"Keine Haltestelle gefunden – Eingabe wird als Freitext gespeichert.";
|
||||
list.appendChild(empty);
|
||||
list.hidden = false;
|
||||
if (opts.emptyText) {
|
||||
var empty = document.createElement("div");
|
||||
empty.className = "combo-empty";
|
||||
empty.textContent = opts.emptyText;
|
||||
list.appendChild(empty);
|
||||
list.hidden = false;
|
||||
} else {
|
||||
close();
|
||||
}
|
||||
return;
|
||||
}
|
||||
items.forEach(function (stop, i) {
|
||||
var opt = document.createElement("div");
|
||||
opt.className = "combo-option" + (i === active ? " active" : "");
|
||||
var name = document.createElement("span");
|
||||
name.className = "name";
|
||||
name.textContent = stop.name;
|
||||
opt.appendChild(name);
|
||||
if (stop.municipality) {
|
||||
var muni = document.createElement("span");
|
||||
muni.className = "muni";
|
||||
muni.textContent = stop.municipality;
|
||||
opt.appendChild(muni);
|
||||
}
|
||||
var kind = document.createElement("span");
|
||||
kind.className = "kind";
|
||||
kind.textContent = TYPE_LABEL[stop.type] || "";
|
||||
opt.appendChild(kind);
|
||||
opt.addEventListener("mousedown", function (e) {
|
||||
e.preventDefault();
|
||||
choose(i);
|
||||
});
|
||||
list.appendChild(opt);
|
||||
items.forEach(function (item, i) {
|
||||
var el = document.createElement("div");
|
||||
el.className = "combo-option" + (i === active ? " active" : "");
|
||||
opts.fillOption(item, el);
|
||||
el.addEventListener("mousedown", function (e) { e.preventDefault(); choose(i); });
|
||||
list.appendChild(el);
|
||||
});
|
||||
list.hidden = false;
|
||||
}
|
||||
|
||||
function choose(i) {
|
||||
var stop = items[i];
|
||||
if (!stop) return;
|
||||
input.value = stop.name;
|
||||
markMatch(stop);
|
||||
if (!items[i]) return;
|
||||
opts.onChoose(items[i]);
|
||||
close();
|
||||
}
|
||||
|
||||
var search = debounce(function () {
|
||||
var run = debounce(function () {
|
||||
var q = input.value.trim();
|
||||
lastQuery = q;
|
||||
if (q.length < 2) { items = []; close(); return; }
|
||||
fetch("/api/stops?q=" + encodeURIComponent(q))
|
||||
.then(function (r) { return r.ok ? r.json() : []; })
|
||||
if (q.length < minChars && minChars > 0) { items = []; close(); return; }
|
||||
Promise.resolve(opts.fetchItems(q))
|
||||
.then(function (data) {
|
||||
if (input.value.trim() !== lastQuery) return;
|
||||
items = data;
|
||||
items = data || [];
|
||||
active = -1;
|
||||
render();
|
||||
var exact = data.filter(function (s) {
|
||||
return s.name.toLowerCase() === lastQuery.toLowerCase();
|
||||
})[0];
|
||||
if (exact) { markMatch(exact); } else { clearMatch(); }
|
||||
if (opts.afterFetch) opts.afterFetch(items, lastQuery);
|
||||
})
|
||||
.catch(function () { items = []; close(); });
|
||||
}, 180);
|
||||
}, 160);
|
||||
|
||||
input.setAttribute("autocomplete", "off");
|
||||
input.addEventListener("input", function () { clearMatch(); search(); });
|
||||
input.addEventListener("focus", function () { if (items.length) render(); });
|
||||
input.addEventListener("input", function () {
|
||||
if (opts.onInput) opts.onInput();
|
||||
run();
|
||||
});
|
||||
input.addEventListener("focus", function () {
|
||||
if (items.length) render(); else run();
|
||||
});
|
||||
input.addEventListener("blur", function () { setTimeout(close, 120); });
|
||||
input.addEventListener("keydown", function (e) {
|
||||
if (list.hidden) return;
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
active = Math.min(active + 1, items.length - 1);
|
||||
render();
|
||||
} else if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
active = Math.max(active - 1, 0);
|
||||
render();
|
||||
} else if (e.key === "Enter") {
|
||||
if (active >= 0) { e.preventDefault(); choose(active); }
|
||||
} else if (e.key === "Escape") {
|
||||
close();
|
||||
if (e.key === "ArrowDown") { e.preventDefault(); active = Math.min(active + 1, items.length - 1); render(); }
|
||||
else if (e.key === "ArrowUp") { e.preventDefault(); active = Math.max(active - 1, 0); render(); }
|
||||
else if (e.key === "Enter" && active >= 0) { e.preventDefault(); choose(active); }
|
||||
else if (e.key === "Escape") { close(); }
|
||||
});
|
||||
|
||||
return { refresh: run, closeList: close };
|
||||
}
|
||||
|
||||
function setupStopCombo(input, form) {
|
||||
var wrap = input.closest(".combo");
|
||||
var hidden = wrap.querySelector(input.dataset.stopTarget);
|
||||
var hint = wrap.querySelector("[data-stop-hint]");
|
||||
if (!hidden) return;
|
||||
|
||||
function clearMatch() {
|
||||
if (!hidden.value) return;
|
||||
hidden.value = "";
|
||||
if (hint) { hint.textContent = ""; hint.classList.remove("matched"); }
|
||||
form.dispatchEvent(new CustomEvent("stopchange"));
|
||||
}
|
||||
function markMatch(stop) {
|
||||
hidden.value = String(stop.id);
|
||||
if (hint) {
|
||||
hint.textContent = "✓ " + stop.name +
|
||||
(stop.municipality ? ", " + stop.municipality : "");
|
||||
hint.classList.add("matched");
|
||||
}
|
||||
form.dispatchEvent(new CustomEvent("stopchange"));
|
||||
}
|
||||
|
||||
attachCombo(input, {
|
||||
emptyText: "Keine Haltestelle gefunden – Eingabe wird als Freitext gespeichert.",
|
||||
onInput: clearMatch,
|
||||
fetchItems: function (q) {
|
||||
return fetch("/api/stops?q=" + encodeURIComponent(q))
|
||||
.then(function (r) { return r.ok ? r.json() : []; });
|
||||
},
|
||||
fillOption: function (stop, el) {
|
||||
var name = document.createElement("span");
|
||||
name.className = "name";
|
||||
name.textContent = stop.name;
|
||||
el.appendChild(name);
|
||||
if (stop.municipality) {
|
||||
var m = document.createElement("span");
|
||||
m.className = "muni";
|
||||
m.textContent = stop.municipality;
|
||||
el.appendChild(m);
|
||||
}
|
||||
var kind = document.createElement("span");
|
||||
kind.className = "kind";
|
||||
kind.textContent = TYPE_LABEL[stop.type] || "";
|
||||
el.appendChild(kind);
|
||||
},
|
||||
onChoose: function (stop) { input.value = stop.name; markMatch(stop); },
|
||||
afterFetch: function (data, q) {
|
||||
var exact = data.filter(function (s) {
|
||||
return s.name.toLowerCase() === q.toLowerCase();
|
||||
})[0];
|
||||
if (exact) markMatch(exact); else clearMatch();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
document.querySelectorAll("[data-stop-input]").forEach(setup);
|
||||
function setupLineCombo(input, form) {
|
||||
var touched = false;
|
||||
var hint = input.closest(".combo").querySelector("[data-line-hint]");
|
||||
input.addEventListener("input", function () { touched = true; });
|
||||
|
||||
function context() {
|
||||
var o = form.querySelector("#origin_stop_id");
|
||||
var d = form.querySelector("#destination_stop_id");
|
||||
return {
|
||||
origin_stop_id: (o && o.value) || "",
|
||||
destination_stop_id: (d && d.value) || "",
|
||||
};
|
||||
}
|
||||
|
||||
var combo = attachCombo(input, {
|
||||
minChars: 0,
|
||||
onInput: function () { touched = true; },
|
||||
fetchItems: function (q) {
|
||||
var ctx = context();
|
||||
if (!ctx.origin_stop_id && !ctx.destination_stop_id) return [];
|
||||
var qs = "origin_stop_id=" + encodeURIComponent(ctx.origin_stop_id) +
|
||||
"&destination_stop_id=" + encodeURIComponent(ctx.destination_stop_id) +
|
||||
"&q=" + encodeURIComponent(q);
|
||||
return fetch("/api/lines?" + qs).then(function (r) { return r.ok ? r.json() : []; });
|
||||
},
|
||||
fillOption: function (line, el) {
|
||||
var ref = document.createElement("span");
|
||||
ref.className = "name";
|
||||
ref.textContent = line.ref;
|
||||
el.appendChild(ref);
|
||||
if (line.both) {
|
||||
var tag = document.createElement("span");
|
||||
tag.className = "kind";
|
||||
tag.textContent = "beide Halte";
|
||||
el.appendChild(tag);
|
||||
}
|
||||
},
|
||||
onChoose: function (line) { input.value = line.ref; touched = true; },
|
||||
});
|
||||
|
||||
// Re-scope suggestions when a stop is picked/cleared; pre-fill an
|
||||
// unambiguous line into an untouched field.
|
||||
form.addEventListener("stopchange", function () {
|
||||
var ctx = context();
|
||||
if (!ctx.origin_stop_id && !ctx.destination_stop_id) return;
|
||||
var qs = "origin_stop_id=" + encodeURIComponent(ctx.origin_stop_id) +
|
||||
"&destination_stop_id=" + encodeURIComponent(ctx.destination_stop_id);
|
||||
fetch("/api/lines?" + qs)
|
||||
.then(function (r) { return r.ok ? r.json() : []; })
|
||||
.then(function (lines) {
|
||||
if (!touched && input.value.trim() === "" && lines.length === 1) {
|
||||
input.value = lines[0].ref;
|
||||
}
|
||||
if (hint) {
|
||||
hint.textContent = lines.length
|
||||
? lines.length + " Linie(n) an den gewählten Halten"
|
||||
: "Keine Linien bekannt – Freitext";
|
||||
}
|
||||
if (document.activeElement === input) combo.refresh();
|
||||
})
|
||||
.catch(function () {});
|
||||
});
|
||||
}
|
||||
|
||||
document.querySelectorAll("form").forEach(function (form) {
|
||||
form.querySelectorAll("[data-stop-input]").forEach(function (i) { setupStopCombo(i, form); });
|
||||
form.querySelectorAll("[data-line-input]").forEach(function (i) { setupLineCombo(i, form); });
|
||||
});
|
||||
})();
|
||||
|
||||
Reference in New Issue
Block a user