Verwaltung: eine Suche ueber alles, mit Tastatur bedienbar
Bisher gab es genau ein Suchfeld, und es durchsuchte nur die Anfragenliste. Wer den Namen eines Kunden im Kopf hatte, musste raten, in welchem Reiter er nachsehen muss: War das eine Anfrage, ein laufendes Projekt, eine offene Rechnung? Bei drei Vorgaengen merkt man sich das, bei dreissig nicht mehr. Jetzt: Strg+K von ueberall, auf dem Handy der Lupenknopf oben. Ein Aufruf durchsucht Anfragen, Kunden, Projekte und Zahlungen; jeder Treffer traegt seinen Zusammenhang (Nummer, Kunde, Betrag, Liefertermin) und fuehrt per Enter in den passenden Reiter, bei einer Anfrage direkt in die Detailansicht. Gebaut nach dem ARIA-Muster 'Combobox mit Listbox-Popup' aus den W3C Authoring Practices -- nachgeschlagen, nicht aus dem Gedaechtnis: role=combobox am Eingabefeld, aria-expanded, aria-controls, aria-activedescendant, role=listbox, role=option mit aria-selected. Der Fokus bleibt dabei im Eingabefeld, damit man weitertippen kann; die Auswahl wandert ueber aria-activedescendant. Ohne diese Auszeichnung waere ein Feld, das Vorschlaege einblendet, fuer einen Screenreader stumm -- das sieht man beim Testen mit den Augen nie. Vier Fallen ausdruecklich behandelt: - Nicht bei jedem Tastendruck suchen (180 ms Wartezeit): 'Musterbau' haette sonst neun Abfragen ausgeloest, acht davon veraltet. - Das Wettrennen der Antworten: Jede Abfrage bekommt eine laufende Nummer, nur die neueste darf zeichnen. Sonst ueberschreibt eine spaet eintreffende alte Antwort die neue. - Leer, laedt und Fehler sind eigene Zustaende. Ein Kasten, der bei einem Serverfehler leer bleibt, sieht aus wie 'nichts gefunden'. - LIKE-Sonderzeichen: '%' waere ein Platzhalter, '_' ein beliebiges Zeichen -- und Unterstriche stehen regelmaessig in E-Mail-Adressen. Der Fehler ist tueckisch, weil die Suche trotzdem Treffer liefert, nur die falschen. Ausserdem zum dritten Mal dieselbe Spezifitaetsfalle gefunden: '.wd p' schlug die eigene Regel, der Tastaturhinweis erschien in 17,9px statt 11,5px -- so gross wie der Inhalt, den er erklaert. Jetzt festgenagelt durch eine Pruefung, die Groessenverhaeltnisse vergleicht. Geprueft: 28 gegen eine echte Datenbank (darunter alle LIKE-Sonderzeichen), 64 im Browser auf Computer und Handy inkl. der ARIA-Vorgaben, des Wettrennens und des Fehlerzustands. Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
+290
@@ -0,0 +1,290 @@
|
||||
/* =====================================================================
|
||||
pruef-suche.mjs — die Suche über alles
|
||||
|
||||
Geprüft wird gegen das ARIA-Muster "Combobox mit Listbox-Popup" aus den
|
||||
W3C Authoring Practices, nicht gegen mein Gefühl. Die Vorgaben dort
|
||||
sind konkret: role=combobox am EINGABEFELD, aria-expanded, aria-controls,
|
||||
aria-activedescendant auf die ausgewählte Option, role=listbox am
|
||||
Popup, role=option mit aria-selected an den Einträgen.
|
||||
|
||||
Warum das hier wirklich zählt: Ohne diese Auszeichnung ist ein Feld,
|
||||
das Vorschläge einblendet, für einen Screenreader stumm. Man tippt, es
|
||||
erscheinen Treffer, angesagt wird nichts. Das sieht man beim Testen
|
||||
mit den Augen nie.
|
||||
|
||||
Zusätzlich die drei Zustände, die man beim Bauen gern vergisst: leer,
|
||||
lädt, Fehler. Ein Kasten, der bei einem Serverfehler leer bleibt,
|
||||
sieht aus wie "nichts gefunden" — und man sucht das Problem an der
|
||||
falschen Stelle.
|
||||
===================================================================== */
|
||||
import { chromium, devices } from "playwright";
|
||||
import { createServer } from "node:http";
|
||||
import { readFileSync, existsSync } from "node:fs";
|
||||
import { join, extname } from "node:path";
|
||||
|
||||
const W = process.cwd();
|
||||
const T = { ".html": "text/html", ".css": "text/css", ".js": "text/javascript",
|
||||
".png": "image/png", ".jpg": "image/jpeg", ".svg": "image/svg+xml",
|
||||
".webp": "image/webp", ".ico": "image/x-icon", ".json": "application/json" };
|
||||
const srv = createServer((q, r) => {
|
||||
const p = join(W, decodeURIComponent(q.url.split("?")[0]));
|
||||
if (existsSync(p) && !p.endsWith("/")) {
|
||||
r.writeHead(200, { "content-type": T[extname(p)] || "text/plain" });
|
||||
return r.end(readFileSync(p));
|
||||
}
|
||||
r.writeHead(404).end("");
|
||||
});
|
||||
await new Promise((r) => srv.listen(4353, r));
|
||||
|
||||
const ergebnisse = [];
|
||||
function pruefe(name, ok, zusatz) {
|
||||
ergebnisse.push({ name, ok });
|
||||
console.log((ok ? " ok " : " FEHL ") + name + (zusatz !== undefined ? " -> " + zusatz : ""));
|
||||
}
|
||||
|
||||
const TREFFER = [
|
||||
{ art: "anfrage", id: "a1", titel: "Anna Muster", unten: "A-2608-0001 · [email protected]", marke: "neu", ziel: "anfragen" },
|
||||
{ art: "kunde", id: "k1", titel: "Anna Muster", unten: "Musterbau GmbH · [email protected]", marke: "aktiv", ziel: "kunden" },
|
||||
{ art: "projekt", id: "p1", titel: "Website für Musterbau", unten: "P-2608-0001 · Anna Muster", marke: "design", ziel: "projekte" },
|
||||
{ art: "zahlung", id: "z1", titel: "Anzahlung Website", unten: "Z-2608-0001 · Anna Muster · 300,00 €", marke: "offen", ziel: "zahlungen" },
|
||||
];
|
||||
|
||||
for (const [wo, angaben] of [
|
||||
["Computer", { viewport: { width: 1440, height: 950 } }],
|
||||
["Handy", { ...devices["iPhone 14 Pro"] }],
|
||||
]) {
|
||||
const browser = await chromium.launch();
|
||||
const kontext = await browser.newContext(angaben);
|
||||
const seite = await kontext.newPage();
|
||||
const protokoll = [];
|
||||
seite.on("pageerror", (e) => protokoll.push(e.message));
|
||||
|
||||
let abfragen = [], langsamMachen = false, fehlerWerfen = false;
|
||||
|
||||
await seite.route("**/webdesign/admin/**", async (route) => {
|
||||
const u = new URL(route.request().url());
|
||||
if (u.pathname.endsWith("/admin/suche")) {
|
||||
const q = u.searchParams.get("q") || "";
|
||||
abfragen.push(q);
|
||||
if (fehlerWerfen) return route.fulfill({ status: 500, json: { ok: false, error: "Serverfehler" } });
|
||||
if (langsamMachen) await new Promise((r) => setTimeout(r, 600));
|
||||
if (q.trim().length < 2) return route.fulfill({ json: { ok: true, begriff: q, zuKurz: true, treffer: [], anzahl: 0 } });
|
||||
if (/nix/.test(q)) return route.fulfill({ json: { ok: true, begriff: q, zuKurz: false, treffer: [], anzahl: 0 } });
|
||||
return route.fulfill({ json: { ok: true, begriff: q, zuKurz: false, treffer: TREFFER,
|
||||
anzahl: TREFFER.length, begrenzt: false, grenzeJeBereich: 6 } });
|
||||
}
|
||||
if (u.pathname.endsWith("/admin/uebersicht")) {
|
||||
return route.fulfill({ json: { ok: true,
|
||||
beiMir: { anfragen: { n: 0 }, nachrichten: { n: 0 }, wuensche: { n: 0 }, widerrufe: { n: 0 }, projekte: 0 },
|
||||
beiKunde: { projekte: 0, aufgaben: 0, zahlungen: { n: 0, summe: 0 }, zahlungenText: "0,00 €" },
|
||||
geld: { monat: { n: 0, brutto: 0, netto: 0, bruttoText: "0,00 €", nettoText: "0,00 €" },
|
||||
gesamt: { n: 0, bruttoText: "0,00 €" }, gebuehrenText: "0,00 €" },
|
||||
projekte: [], verlauf: [], bestand: {} } });
|
||||
}
|
||||
return route.fulfill({ json: { ok: true, anfragen: [], kunden: [], projekte: [], zahlungen: [], verlaeufe: [] } });
|
||||
});
|
||||
await seite.route("**/auth/**", (r) => r.fulfill({ json: { ok: true, user: { name: "Test" } } }));
|
||||
await seite.addInitScript(() => {
|
||||
sessionStorage.setItem("wd-vw-token", "t");
|
||||
sessionStorage.setItem("wd-sitzung-aktiv", "1");
|
||||
});
|
||||
|
||||
await seite.goto("http://127.0.0.1:4353/webdesign/verwaltung.html");
|
||||
await seite.waitForTimeout(700);
|
||||
console.log("\n=== " + wo + " ===");
|
||||
|
||||
/* ---- 1. Öffnen ---- */
|
||||
pruefe(wo + ": die Palette ist zunaechst zu",
|
||||
!(await seite.locator("#vw-palette").isVisible()));
|
||||
await seite.click("#vw-suche-auf");
|
||||
await seite.waitForTimeout(300);
|
||||
pruefe(wo + ": der Lupenknopf oeffnet sie", await seite.locator("#vw-palette").isVisible());
|
||||
pruefe(wo + ": der Fokus steht sofort im Suchfeld",
|
||||
await seite.evaluate(() => document.activeElement.id === "vw-pal-feld"),
|
||||
await seite.evaluate(() => document.activeElement.id));
|
||||
|
||||
/* ---- 2. ARIA im Ruhezustand ---- */
|
||||
const ruhe = await seite.evaluate(() => {
|
||||
const f = document.getElementById("vw-pal-feld");
|
||||
return { rolle: f.getAttribute("role"), auf: f.getAttribute("aria-expanded"),
|
||||
steuert: f.getAttribute("aria-controls"), auto: f.getAttribute("aria-autocomplete"),
|
||||
listenRolle: document.getElementById("vw-pal-liste").getAttribute("role") };
|
||||
});
|
||||
pruefe(wo + ": das Eingabefeld traegt role=combobox", ruhe.rolle === "combobox", ruhe.rolle);
|
||||
pruefe(wo + ": aria-expanded ist ohne Treffer false", ruhe.auf === "false", ruhe.auf);
|
||||
pruefe(wo + ": aria-controls zeigt auf die Liste", ruhe.steuert === "vw-pal-liste", ruhe.steuert);
|
||||
pruefe(wo + ": aria-autocomplete=list ist gesetzt", ruhe.auto === "list", ruhe.auto);
|
||||
pruefe(wo + ": die Liste traegt role=listbox", ruhe.listenRolle === "listbox", ruhe.listenRolle);
|
||||
|
||||
/* ---- 3. Zu kurz ist ein eigener Zustand ---- */
|
||||
await seite.fill("#vw-pal-feld", "a");
|
||||
await seite.waitForTimeout(450);
|
||||
pruefe(wo + ": ein einzelner Buchstabe zeigt einen Hinweis, keine leere Liste",
|
||||
/Mindestens zwei/.test(await seite.locator("#vw-pal-liste").innerText()),
|
||||
await seite.locator("#vw-pal-liste").innerText());
|
||||
|
||||
/* ---- 4. Suchen und ARIA bei Treffern ---- */
|
||||
abfragen = [];
|
||||
await seite.fill("#vw-pal-feld", "Muster");
|
||||
await seite.waitForTimeout(500);
|
||||
const treffer = await seite.locator('#vw-pal-liste [role="option"]').count();
|
||||
pruefe(wo + ": alle vier Treffer erscheinen", treffer === 4, treffer);
|
||||
pruefe(wo + ": jede Art ist erkennbar",
|
||||
(await seite.locator("#vw-pal-liste").innerText()).includes("ANFRAGE")
|
||||
|| (await seite.locator("#vw-pal-liste").innerText()).toLowerCase().includes("anfrage"),
|
||||
(await seite.locator("#vw-pal-liste").innerText()).slice(0, 60));
|
||||
|
||||
const mitTreffern = await seite.evaluate(() => {
|
||||
const f = document.getElementById("vw-pal-feld");
|
||||
const erste = document.querySelector('#vw-pal-liste [role="option"]');
|
||||
return { auf: f.getAttribute("aria-expanded"), aktiv: f.getAttribute("aria-activedescendant"),
|
||||
ersteId: erste.id, gewaehlt: erste.getAttribute("aria-selected") };
|
||||
});
|
||||
pruefe(wo + ": aria-expanded steht jetzt auf true", mitTreffern.auf === "true", mitTreffern.auf);
|
||||
pruefe(wo + ": aria-activedescendant zeigt auf den ersten Treffer",
|
||||
mitTreffern.aktiv === mitTreffern.ersteId, mitTreffern.aktiv + " vs. " + mitTreffern.ersteId);
|
||||
pruefe(wo + ": der erste Treffer ist aria-selected", mitTreffern.gewaehlt === "true", mitTreffern.gewaehlt);
|
||||
pruefe(wo + ": die Trefferzahl wird angesagt (role=status)",
|
||||
/4 Treffer/.test(await seite.locator("#vw-pal-zahl").innerText()),
|
||||
await seite.locator("#vw-pal-zahl").innerText());
|
||||
|
||||
/* ---- 4b. Groessenverhaeltnisse ----
|
||||
Der Tastaturhinweis unten darf nicht so gross sein wie der Inhalt,
|
||||
den er erklaert. Genau das passierte, solange die Regel .wd p die
|
||||
eigene ueberstimmte: 17,9px statt 11,5px. Dieselbe Falle wie schon
|
||||
dreimal zuvor in diesem Projekt, deshalb hier festgenagelt. */
|
||||
const groessen = await seite.evaluate(() => ({
|
||||
fuss: parseFloat(getComputedStyle(document.querySelector(".vw-palette-fuss")).fontSize),
|
||||
zahl: parseFloat(getComputedStyle(document.querySelector(".vw-palette-zahl")).fontSize),
|
||||
titel: parseFloat(getComputedStyle(document.querySelector(".vw-pal-titel")).fontSize),
|
||||
}));
|
||||
pruefe(wo + ": der Tastaturhinweis ist kleiner als ein Treffer",
|
||||
groessen.fuss < groessen.titel,
|
||||
"Hinweis " + groessen.fuss + "px, Treffer " + groessen.titel + "px");
|
||||
pruefe(wo + ": der Zaehler ebenfalls",
|
||||
groessen.zahl < groessen.titel,
|
||||
"Zaehler " + groessen.zahl + "px, Treffer " + groessen.titel + "px");
|
||||
|
||||
/* ---- 5. Nicht bei jedem Tastendruck suchen ---- */
|
||||
pruefe(wo + ": es ging genau EINE Abfrage hinaus, nicht sechs",
|
||||
abfragen.length === 1, abfragen.length + ": " + abfragen.join(","));
|
||||
|
||||
/* ---- 6. Tastaturbedienung ---- */
|
||||
await seite.keyboard.press("ArrowDown");
|
||||
await seite.waitForTimeout(150);
|
||||
let nach = await seite.evaluate(() => ({
|
||||
aktiv: document.getElementById("vw-pal-feld").getAttribute("aria-activedescendant"),
|
||||
fokus: document.activeElement.id,
|
||||
}));
|
||||
pruefe(wo + ": Pfeil runter waehlt den zweiten Treffer", nach.aktiv === "vw-pal-o1", nach.aktiv);
|
||||
/* Der wichtigste Punkt des ARIA-Musters: Der Fokus bleibt im Feld,
|
||||
damit man weitertippen kann. */
|
||||
pruefe(wo + ": der Fokus bleibt dabei im Eingabefeld", nach.fokus === "vw-pal-feld", nach.fokus);
|
||||
|
||||
await seite.keyboard.press("ArrowUp");
|
||||
await seite.keyboard.press("ArrowUp");
|
||||
await seite.waitForTimeout(150);
|
||||
nach = await seite.evaluate(() => document.getElementById("vw-pal-feld").getAttribute("aria-activedescendant"));
|
||||
pruefe(wo + ": Pfeil hoch laeuft am Anfang zum Ende um", nach === "vw-pal-o3", nach);
|
||||
|
||||
/* ---- 7. Enter springt in den richtigen Reiter ---- */
|
||||
await seite.fill("#vw-pal-feld", "Muster");
|
||||
await seite.waitForTimeout(500);
|
||||
await seite.keyboard.press("ArrowDown");
|
||||
await seite.keyboard.press("ArrowDown"); // Projekt
|
||||
await seite.keyboard.press("Enter");
|
||||
await seite.waitForTimeout(600);
|
||||
pruefe(wo + ": die Palette schliesst sich nach der Auswahl",
|
||||
!(await seite.locator("#vw-palette").isVisible()));
|
||||
pruefe(wo + ": und der passende Reiter ist offen",
|
||||
(await seite.locator("#vw-titel").innerText()).trim() === "Projekte",
|
||||
await seite.locator("#vw-titel").innerText());
|
||||
|
||||
/* ---- 8. Escape und Fokusrueckgabe ---- */
|
||||
await seite.click("#vw-suche-auf");
|
||||
await seite.waitForTimeout(250);
|
||||
await seite.keyboard.press("Escape");
|
||||
await seite.waitForTimeout(250);
|
||||
pruefe(wo + ": Escape schliesst", !(await seite.locator("#vw-palette").isVisible()));
|
||||
pruefe(wo + ": der Fokus kehrt zum Lupenknopf zurueck",
|
||||
await seite.evaluate(() => document.activeElement.id === "vw-suche-auf"),
|
||||
await seite.evaluate(() => document.activeElement.id));
|
||||
|
||||
/* ---- 9. Nichts gefunden ---- */
|
||||
await seite.click("#vw-suche-auf");
|
||||
await seite.fill("#vw-pal-feld", "nixdagibtes");
|
||||
await seite.waitForTimeout(500);
|
||||
const leer = await seite.locator("#vw-pal-liste").innerText();
|
||||
pruefe(wo + ": 'nichts gefunden' steht ausdruecklich da", /Nichts gefunden/.test(leer), leer);
|
||||
pruefe(wo + ": und aria-expanded geht wieder auf false",
|
||||
(await seite.evaluate(() => document.getElementById("vw-pal-feld").getAttribute("aria-expanded"))) === "false");
|
||||
|
||||
/* ---- 10. Ein Serverfehler darf NICHT wie 'nichts gefunden' aussehen ---- */
|
||||
fehlerWerfen = true;
|
||||
await seite.fill("#vw-pal-feld", "Muster");
|
||||
await seite.waitForTimeout(600);
|
||||
const fehlerText = await seite.locator("#vw-pal-liste").innerText();
|
||||
pruefe(wo + ": ein Serverfehler wird als Fehler benannt",
|
||||
/fehlgeschlagen/i.test(fehlerText), fehlerText);
|
||||
pruefe(wo + ": und sieht anders aus als 'nichts gefunden'",
|
||||
!/Nichts gefunden/.test(fehlerText), fehlerText);
|
||||
fehlerWerfen = false;
|
||||
|
||||
/* ---- 11. Das Wettrennen der Antworten ---- */
|
||||
/* Die erste Abfrage wird kuenstlich langsam gemacht. Kommt sie NACH
|
||||
der zweiten an, darf sie nicht mehr zeichnen -- sonst sieht man
|
||||
beim Weitertippen ploetzlich wieder die alten Treffer. */
|
||||
await seite.fill("#vw-pal-feld", "");
|
||||
await seite.waitForTimeout(300);
|
||||
langsamMachen = true;
|
||||
await seite.fill("#vw-pal-feld", "langsam");
|
||||
await seite.waitForTimeout(250);
|
||||
langsamMachen = false;
|
||||
await seite.fill("#vw-pal-feld", "nixdagibtes");
|
||||
await seite.waitForTimeout(1200);
|
||||
const zuletzt = await seite.locator("#vw-pal-liste").innerText();
|
||||
pruefe(wo + ": die spaet eintreffende alte Antwort ueberschreibt die neue NICHT",
|
||||
/Nichts gefunden/.test(zuletzt), zuletzt.slice(0, 80));
|
||||
|
||||
await seite.keyboard.press("Escape");
|
||||
pruefe(wo + ": kein Skriptfehler", protokoll.length === 0, protokoll.join(" | "));
|
||||
|
||||
/* ---- 12. Bild ---- */
|
||||
await seite.click("#vw-suche-auf");
|
||||
await seite.fill("#vw-pal-feld", "Muster");
|
||||
await seite.waitForTimeout(500);
|
||||
await seite.screenshot({ path: "bild-suche-" + wo.toLowerCase() + ".png" });
|
||||
await browser.close();
|
||||
}
|
||||
|
||||
/* Strg+K nur auf dem Computer -- ein Handy hat keine Strg-Taste. */
|
||||
{
|
||||
const browser = await chromium.launch();
|
||||
const seite = await browser.newPage({ viewport: { width: 1440, height: 950 } });
|
||||
await seite.route("**/webdesign/admin/**", (route) => route.fulfill({ json: { ok: true,
|
||||
beiMir: { anfragen: { n: 0 }, nachrichten: { n: 0 }, wuensche: { n: 0 }, widerrufe: { n: 0 }, projekte: 0 },
|
||||
beiKunde: { projekte: 0, aufgaben: 0, zahlungen: { n: 0, summe: 0 }, zahlungenText: "0 €" },
|
||||
geld: { monat: { n: 0, bruttoText: "0 €", nettoText: "0 €" }, gesamt: { n: 0, bruttoText: "0 €" }, gebuehrenText: "0 €" },
|
||||
projekte: [], verlauf: [], bestand: {}, treffer: [], anfragen: [], kunden: [] } }));
|
||||
await seite.route("**/auth/**", (r) => r.fulfill({ json: { ok: true } }));
|
||||
await seite.addInitScript(() => {
|
||||
sessionStorage.setItem("wd-vw-token", "t");
|
||||
sessionStorage.setItem("wd-sitzung-aktiv", "1");
|
||||
});
|
||||
await seite.goto("http://127.0.0.1:4353/webdesign/verwaltung.html");
|
||||
await seite.waitForTimeout(700);
|
||||
console.log("\n=== Tastenkuerzel ===");
|
||||
await seite.keyboard.press("Control+k");
|
||||
await seite.waitForTimeout(300);
|
||||
pruefe("Strg+K oeffnet die Suche von ueberall",
|
||||
await seite.locator("#vw-palette").isVisible());
|
||||
await seite.keyboard.press("Control+k");
|
||||
await seite.waitForTimeout(300);
|
||||
pruefe("und schliesst sie wieder", !(await seite.locator("#vw-palette").isVisible()));
|
||||
await browser.close();
|
||||
}
|
||||
|
||||
srv.close();
|
||||
const f = ergebnisse.filter((e) => !e.ok).length;
|
||||
console.log(`\n===== ${ergebnisse.length - f} bestanden, ${f} fehlgeschlagen =====`);
|
||||
process.exitCode = f ? 1 : 0;
|
||||
@@ -129,6 +129,7 @@ import {
|
||||
import {
|
||||
annahmeVorschau, anfrageAnnehmen, absageVorschau, anfrageAblehnen,
|
||||
} from "./routes/webdesign-annahme.js";
|
||||
import { suche } from "./routes/webdesign-suche.js";
|
||||
/* PayPal-Zugangsdaten koennen verschluesselt in der Datenbank liegen,
|
||||
damit Filipe sie in seiner Verwaltung eintragen kann statt per SSH.
|
||||
Sie muessen beim Start EINMAL entschluesselt werden, weil die
|
||||
@@ -361,6 +362,7 @@ app.post("/webdesign/portal/zahlung/:id/abschluss", wrap(zahlungAbschliessen));
|
||||
app.get("/webdesign/admin/zahlungen", wrap(zahlungenListe));
|
||||
app.post("/webdesign/admin/zahlungen", wrap(zahlungAnlegen));
|
||||
app.get("/webdesign/admin/uebersicht", wrap(uebersicht));
|
||||
app.get("/webdesign/admin/suche", wrap(suche));
|
||||
app.get("/webdesign/admin/einstellungen", wrap(einstellungenLesen));
|
||||
app.post("/webdesign/admin/einstellungen", wrap(einstellungenSchreiben));
|
||||
app.post("/webdesign/admin/einstellungen/pruefen", wrap(einstellungenPruefen));
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
/* =====================================================================
|
||||
webdesign-suche.js — eine Suche über alles
|
||||
|
||||
WARUM ES DIE BRAUCHT
|
||||
|
||||
Bisher gab es genau ein Suchfeld, und es durchsuchte nur die
|
||||
Anfragenliste. Wer den Namen eines Kunden im Kopf hatte, musste
|
||||
raten, in welchem Reiter er nachsehen muss: War das eine Anfrage,
|
||||
ein laufendes Projekt, eine offene Rechnung? Bei drei Vorgängen
|
||||
merkt man sich das. Bei dreissig nicht mehr.
|
||||
|
||||
DREI ENTSCHEIDUNGEN
|
||||
|
||||
1. EIN AUFRUF FÜR ALLES. Vier einzelne Abfragen würden die Liste
|
||||
ruckelnd aufbauen und bei jedem getippten Buchstaben viermal
|
||||
losgeschickt. Hier kommt alles zusammen zurück, sortiert.
|
||||
|
||||
2. GESUCHT WIRD, WAS MAN IM KOPF HAT. Also Name, E-Mail, Firma,
|
||||
Projekttitel, Vorgangsnummer -- nicht Beschreibungstexte. Wer
|
||||
"Muster" tippt, meint den Kunden Muster, nicht jede Anfrage, in
|
||||
deren Fliesstext das Wort vorkommt. Volltextsuche über alles
|
||||
klingt mächtig und liefert vor allem Rauschen.
|
||||
|
||||
3. TREFFER TRAGEN IHREN ZUSAMMENHANG MIT. Ein Ergebnis "Anna Muster"
|
||||
allein hilft nicht -- es steht dabei, ob das eine Anfrage von
|
||||
vorgestern oder ein laufendes Projekt ist. Sonst muss man jeden
|
||||
Treffer anklicken, um zu wissen, ob er gemeint war.
|
||||
===================================================================== */
|
||||
import { db } from "../db.js";
|
||||
import { json } from "../lib/http.js";
|
||||
import { darfWebdesign } from "../lib/webdesign-ausweis.js";
|
||||
import { requireSession } from "../lib/auth.js";
|
||||
import { hasPermission } from "../lib/permissions.js";
|
||||
import { text, centZuText } from "../lib/webdesign-helfer.js";
|
||||
|
||||
async function darf(req, recht) {
|
||||
return darfWebdesign(req, recht, async () => {
|
||||
const sitzung = await requireSession(req);
|
||||
if (!sitzung) return { erlaubt: false, grund: 401, sitzung: null };
|
||||
if (sitzung.isOwner) return { erlaubt: true, sitzung };
|
||||
const ok = hasPermission(sitzung.permissions, sitzung.overrides, recht);
|
||||
return { erlaubt: ok, grund: ok ? null : 403, sitzung };
|
||||
});
|
||||
}
|
||||
|
||||
/* Sonderzeichen von LIKE unschädlich machen.
|
||||
|
||||
Ein Prozentzeichen im Suchbegriff wäre sonst ein Platzhalter: Die
|
||||
Suche nach "50%" fände alles, was mit "50" beginnt. Der Unterstrich
|
||||
steht für ein beliebiges Zeichen -- eine Suche nach "a_b" fände
|
||||
"axb". Beides ist kein theoretischer Fall: E-Mail-Adressen enthalten
|
||||
regelmässig Unterstriche.
|
||||
|
||||
Der Backslash als Fluchtzeichen muss ZUERST ersetzt werden, sonst
|
||||
verdoppelt der zweite Durchlauf die eben eingefügten. */
|
||||
function fuerLike(begriff) {
|
||||
return begriff
|
||||
.replace(/\\/g, "\\\\")
|
||||
.replace(/%/g, "\\%")
|
||||
.replace(/_/g, "\\_");
|
||||
}
|
||||
|
||||
const GRENZE = 6; // je Bereich -- mehr passt nicht auf einen Blick
|
||||
|
||||
export async function suche(req, res) {
|
||||
const p = await darf(req, "WD_ANFRAGEN_VIEW");
|
||||
if (!p.erlaubt) return json(res, { ok: false, error: "Kein Zugriff." }, p.grund);
|
||||
|
||||
const roh = text(req.query?.q, 80) || "";
|
||||
const begriff = roh.trim();
|
||||
|
||||
/* Unter zwei Zeichen wird nicht gesucht. Ein einzelner Buchstabe
|
||||
trifft fast alles und ist damit keine Auskunft, sondern eine
|
||||
Liste -- und er belastet die Datenbank bei jedem Tastendruck. */
|
||||
if (begriff.length < 2) {
|
||||
return json(res, { ok: true, begriff, zuKurz: true, treffer: [], anzahl: 0 });
|
||||
}
|
||||
|
||||
const muster = "%" + fuerLike(begriff) + "%";
|
||||
const treffer = [];
|
||||
|
||||
/* ---- Anfragen ---- */
|
||||
for (const a of db.prepare(
|
||||
`SELECT id, nummer, name, email, paket, status, erstellt_am
|
||||
FROM wd_anfragen
|
||||
WHERE archiviert = 0
|
||||
AND (name LIKE ? ESCAPE '\\' OR email LIKE ? ESCAPE '\\' OR nummer LIKE ? ESCAPE '\\')
|
||||
ORDER BY erstellt_am DESC LIMIT ?`
|
||||
).all(muster, muster, muster, GRENZE)) {
|
||||
treffer.push({
|
||||
art: "anfrage", id: a.id, titel: a.name,
|
||||
unten: a.nummer + " · " + (a.email || ""),
|
||||
marke: a.status, ziel: "anfragen",
|
||||
});
|
||||
}
|
||||
|
||||
/* ---- Kunden ---- */
|
||||
for (const k of db.prepare(
|
||||
`SELECT id, name, email, firma, gesperrt, freigeschaltet
|
||||
FROM wd_kunden
|
||||
WHERE archiviert = 0
|
||||
AND (name LIKE ? ESCAPE '\\' OR email LIKE ? ESCAPE '\\' OR firma LIKE ? ESCAPE '\\')
|
||||
ORDER BY name LIMIT ?`
|
||||
).all(muster, muster, muster, GRENZE)) {
|
||||
treffer.push({
|
||||
art: "kunde", id: k.id, titel: k.name,
|
||||
unten: (k.firma ? k.firma + " · " : "") + (k.email || ""),
|
||||
marke: k.gesperrt ? "gesperrt" : (k.freigeschaltet ? "aktiv" : "noch nicht frei"),
|
||||
ziel: "kunden",
|
||||
});
|
||||
}
|
||||
|
||||
/* ---- Projekte ---- */
|
||||
for (const pr of db.prepare(
|
||||
`SELECT p.id, p.nummer, p.titel, p.status, p.termin_am, k.name AS kunde_name
|
||||
FROM wd_projekte p JOIN wd_kunden k ON k.id = p.kunde_id
|
||||
WHERE p.archiviert = 0
|
||||
AND (p.titel LIKE ? ESCAPE '\\' OR p.nummer LIKE ? ESCAPE '\\' OR k.name LIKE ? ESCAPE '\\')
|
||||
ORDER BY p.erstellt_am DESC LIMIT ?`
|
||||
).all(muster, muster, muster, GRENZE)) {
|
||||
treffer.push({
|
||||
art: "projekt", id: pr.id, titel: pr.titel,
|
||||
unten: pr.nummer + " · " + pr.kunde_name
|
||||
+ (pr.termin_am ? " · bis " + pr.termin_am : ""),
|
||||
marke: pr.status, ziel: "projekte",
|
||||
});
|
||||
}
|
||||
|
||||
/* ---- Zahlungen ---- */
|
||||
for (const z of db.prepare(
|
||||
`SELECT z.id, z.nummer, z.zweck_text, z.betrag_cent, z.waehrung, z.status, k.name AS kunde_name
|
||||
FROM wd_zahlungen z LEFT JOIN wd_kunden k ON k.id = z.kunde_id
|
||||
WHERE z.nummer LIKE ? ESCAPE '\\' OR z.zweck_text LIKE ? ESCAPE '\\'
|
||||
OR k.name LIKE ? ESCAPE '\\'
|
||||
ORDER BY z.erstellt_am DESC LIMIT ?`
|
||||
).all(muster, muster, muster, GRENZE)) {
|
||||
treffer.push({
|
||||
art: "zahlung", id: z.id, titel: z.zweck_text || z.nummer,
|
||||
unten: z.nummer + " · " + (z.kunde_name || "—") + " · " + centZuText(z.betrag_cent, z.waehrung),
|
||||
marke: z.status, ziel: "zahlungen",
|
||||
});
|
||||
}
|
||||
|
||||
return json(res, {
|
||||
ok: true,
|
||||
begriff,
|
||||
zuKurz: false,
|
||||
treffer,
|
||||
anzahl: treffer.length,
|
||||
/* Ehrlich sagen, wenn abgeschnitten wurde. Eine Liste, die stillschweigend
|
||||
bei sechs endet, liest sich wie "mehr gibt es nicht" -- und dann sucht
|
||||
man den siebten Kunden vergeblich. */
|
||||
begrenzt: treffer.length >= GRENZE,
|
||||
grenzeJeBereich: GRENZE,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
/* =====================================================================
|
||||
test-suche.mjs — die Suche über alles
|
||||
|
||||
Der heikelste Teil sind die Sonderzeichen von LIKE. Ein Prozentzeichen
|
||||
im Suchbegriff wäre ohne Behandlung ein Platzhalter, ein Unterstrich
|
||||
ebenfalls -- und Unterstriche stehen regelmässig in E-Mail-Adressen.
|
||||
Der Fehler ist tückisch, weil die Suche trotzdem Treffer liefert, nur
|
||||
die falschen. Man merkt es nie.
|
||||
===================================================================== */
|
||||
import { mkdtempSync, rmSync, writeSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { randomBytes, createHmac } from "node:crypto";
|
||||
|
||||
const L = (...a) => writeSync(1, a.join(" ") + "\n");
|
||||
const tmp = mkdtempSync(join(tmpdir(), "wd-suche-"));
|
||||
process.env.DB_PATH = join(tmp, "t.db");
|
||||
process.env.ENCRYPTION_KEY = randomBytes(32).toString("base64");
|
||||
process.env.WEBDESIGN_API_SECRET = randomBytes(24).toString("hex");
|
||||
|
||||
const { initDb, db } = await import("./db.js");
|
||||
initDb();
|
||||
const geheim = await import("./lib/webdesign-geheimnisse.js");
|
||||
await geheim.geheimnisseLaden();
|
||||
const { suche } = await import("./routes/webdesign-suche.js");
|
||||
|
||||
let ok = 0, fehler = 0;
|
||||
const pruefe = (n, b, z) => b
|
||||
? (ok++, L(" ok " + n))
|
||||
: (fehler++, L(" FEHL " + n + (z !== undefined ? " -> " + z : "")));
|
||||
|
||||
function ausweis(gueltigMs = 60_000) {
|
||||
const n = Buffer.from(JSON.stringify({
|
||||
bereich: "wd-admin", rolle: "dogfather", exp: Date.now() + gueltigMs,
|
||||
})).toString("base64url");
|
||||
return n + "." + createHmac("sha256", process.env.WEBDESIGN_API_SECRET).update(n).digest("base64url");
|
||||
}
|
||||
async function such(q, kopf) {
|
||||
let out = null;
|
||||
await suche(
|
||||
{ headers: kopf || { authorization: "Bearer " + ausweis() }, cookies: {}, query: { q }, params: {} },
|
||||
{ status() { return this; }, json(o) { out = o; return this; }, setHeader() {}, end() {} }
|
||||
);
|
||||
return out;
|
||||
}
|
||||
|
||||
const jetzt = new Date().toISOString();
|
||||
db.prepare(`INSERT INTO wd_kunden (id,email,name,firma,erstellt_am) VALUES ('k1','[email protected]','Anna Muster','Musterbau GmbH',?)`).run(jetzt);
|
||||
db.prepare(`INSERT INTO wd_kunden (id,email,name,erstellt_am) VALUES ('k2','[email protected]','Bert Meier',?)`).run(jetzt);
|
||||
db.prepare(`INSERT INTO wd_kunden (id,email,name,erstellt_am,gesperrt) VALUES ('k3','[email protected]','Clara Sperr',?,1)`).run(jetzt);
|
||||
db.prepare(`INSERT INTO wd_anfragen (id,nummer,name,email,paket,status,erstellt_am)
|
||||
VALUES ('a1','A-2608-0001','Anna Muster','[email protected]','website','neu',?)`).run(jetzt);
|
||||
db.prepare(`INSERT INTO wd_anfragen (id,nummer,name,email,paket,status,erstellt_am)
|
||||
VALUES ('a2','A-2608-0002','Dora 50% Rabatt','[email protected]','shop','neu',?)`).run(jetzt);
|
||||
db.prepare(`INSERT INTO wd_projekte (id,nummer,kunde_id,titel,paket,status,erstellt_am,termin_am)
|
||||
VALUES ('p1','P-2608-0001','k1','Website für Musterbau','website','design',?, '2026-10-15')`).run(jetzt);
|
||||
db.prepare(`INSERT INTO wd_zahlungen (id,nummer,kunde_id,art,zweck_text,betrag_cent,status,erstellt_am)
|
||||
VALUES ('z1','Z-2608-0001','k1','anzahlung','Anzahlung Website',30000,'offen',?)`).run(jetzt);
|
||||
|
||||
L("\n1. UEBER ALLE BEREICHE HINWEG");
|
||||
let r = await such("Muster");
|
||||
pruefe("die Suche antwortet", r && r.ok === true, JSON.stringify(r).slice(0, 100));
|
||||
const arten = new Set(r.treffer.map((t) => t.art));
|
||||
pruefe("sie findet die Anfrage", arten.has("anfrage"));
|
||||
pruefe("sie findet den Kunden", arten.has("kunde"));
|
||||
pruefe("sie findet das Projekt", arten.has("projekt"));
|
||||
pruefe("sie findet die Zahlung ueber den KUNDENNAMEN", arten.has("zahlung"));
|
||||
/* Vier Bereiche in einem Aufruf. Die erste Fassung erwartete drei und
|
||||
uebersah, dass die Zahlung ueber den Namen des Kunden mitgefunden wird
|
||||
-- was genau richtig ist: Wer "Muster" sucht, will auch dessen offene
|
||||
Rechnung sehen, ohne sie ueber die Zahlungsnummer zu kennen. */
|
||||
pruefe("alle vier Bereiche in EINEM Aufruf", arten.size === 4, Array.from(arten).join(","));
|
||||
|
||||
L("\n2. JEDER TREFFER TRAEGT SEINEN ZUSAMMENHANG");
|
||||
const projektTreffer = r.treffer.find((t) => t.art === "projekt");
|
||||
pruefe("das Projekt nennt Nummer und Kunde",
|
||||
/P-2608-0001/.test(projektTreffer.unten) && /Anna Muster/.test(projektTreffer.unten),
|
||||
projektTreffer.unten);
|
||||
pruefe("und den Liefertermin", /2026-10-15/.test(projektTreffer.unten), projektTreffer.unten);
|
||||
pruefe("jeder Treffer weiss, wohin er fuehrt",
|
||||
r.treffer.every((t) => !!t.ziel), JSON.stringify(r.treffer.map((t) => t.ziel)));
|
||||
pruefe("jeder Treffer hat eine Marke fuer seinen Stand",
|
||||
r.treffer.every((t) => !!t.marke), JSON.stringify(r.treffer.map((t) => t.marke)));
|
||||
|
||||
L("\n3. SUCHE UEBER DEN KUNDENNAMEN FINDET SEIN PROJEKT");
|
||||
/* Man merkt sich den Menschen, nicht die Projektnummer. */
|
||||
r = await such("Musterbau");
|
||||
pruefe("die Firma des Kunden fuehrt zum Kunden",
|
||||
r.treffer.some((t) => t.art === "kunde" && t.titel === "Anna Muster"));
|
||||
|
||||
L("\n4. ZAHLUNGEN");
|
||||
r = await such("Anzahlung");
|
||||
pruefe("die Zahlung wird gefunden", r.treffer.some((t) => t.art === "zahlung"));
|
||||
const z = r.treffer.find((t) => t.art === "zahlung");
|
||||
pruefe("mit lesbarem Betrag", /300,00/.test(z.unten), z.unten);
|
||||
|
||||
L("\n5. LIKE-SONDERZEICHEN — hier entstehen falsche Treffer");
|
||||
/* Ohne Behandlung waere "%" ein Platzhalter und faende ALLES. */
|
||||
r = await such("%");
|
||||
pruefe("ein einzelnes Prozentzeichen ist zu kurz und sucht gar nicht",
|
||||
r.zuKurz === true, JSON.stringify(r).slice(0, 80));
|
||||
r = await such("50%");
|
||||
pruefe("nach '50%' wird woertlich gesucht",
|
||||
r.treffer.length === 1 && /Dora/.test(r.treffer[0].titel),
|
||||
r.treffer.length + " Treffer: " + r.treffer.map((t) => t.titel).join(", "));
|
||||
/* Ohne Behandlung waere "_" ein beliebiges Zeichen: "bert_meier" faende
|
||||
dann auch "bertXmeier". Geprueft wird umgekehrt -- ein Muster mit
|
||||
Unterstrich darf NICHT auf etwas anderes passen. */
|
||||
r = await such("bert_meier");
|
||||
pruefe("ein Unterstrich wird woertlich genommen",
|
||||
r.treffer.length === 1 && r.treffer[0].art === "kunde",
|
||||
r.treffer.length + " Treffer");
|
||||
r = await such("bertxmeier");
|
||||
pruefe("und passt nicht auf ein beliebiges Zeichen",
|
||||
r.treffer.length === 0, r.treffer.length + " Treffer");
|
||||
|
||||
L("\n6. ZU KURZ WIRD GAR NICHT GESUCHT");
|
||||
r = await such("a");
|
||||
pruefe("ein einzelner Buchstabe sucht nicht", r.zuKurz === true && r.treffer.length === 0);
|
||||
r = await such("");
|
||||
pruefe("leer ebenfalls nicht", r.zuKurz === true);
|
||||
r = await such(" ");
|
||||
pruefe("nur Leerzeichen ebenfalls nicht", r.zuKurz === true);
|
||||
|
||||
L("\n7. NICHTS GEFUNDEN IST EIN EIGENER ZUSTAND");
|
||||
r = await such("gibtesnichtxyz");
|
||||
pruefe("die Antwort ist gueltig", r.ok === true);
|
||||
pruefe("mit null Treffern und NICHT zuKurz",
|
||||
r.anzahl === 0 && r.zuKurz === false, JSON.stringify(r).slice(0, 90));
|
||||
|
||||
L("\n8. GESPERRTE KUNDEN WERDEN GEZEIGT, ABER GEKENNZEICHNET");
|
||||
/* Verstecken waere falsch: Man sucht sie ja gerade, WEIL etwas nicht
|
||||
stimmt. Ohne Kennzeichnung wuerde man sich aber wundern. */
|
||||
r = await such("Sperr");
|
||||
pruefe("der gesperrte Kunde erscheint", r.treffer.some((t) => t.art === "kunde"));
|
||||
pruefe("und ist als gesperrt gekennzeichnet",
|
||||
r.treffer.find((t) => t.art === "kunde").marke === "gesperrt",
|
||||
r.treffer.find((t) => t.art === "kunde").marke);
|
||||
|
||||
L("\n9. ARCHIVIERTES TAUCHT NICHT AUF");
|
||||
db.prepare(`UPDATE wd_anfragen SET archiviert = 1 WHERE id = 'a1'`).run();
|
||||
r = await such("A-2608-0001");
|
||||
pruefe("eine archivierte Anfrage wird nicht mehr gefunden",
|
||||
!r.treffer.some((t) => t.art === "anfrage"), JSON.stringify(r.treffer));
|
||||
|
||||
L("\n10. DIE RECHTEPRUEFUNG GREIFT");
|
||||
for (const [name, kopf] of [
|
||||
["ohne Ausweis kein Ergebnis", {}],
|
||||
["mit gefaelschter Unterschrift kein Ergebnis",
|
||||
{ authorization: "Bearer " + ausweis().split(".")[0] + ".XXXX" }],
|
||||
["mit abgelaufenem Ausweis kein Ergebnis",
|
||||
{ authorization: "Bearer " + ausweis(-1000) }],
|
||||
]) {
|
||||
const a = await such("Muster", kopf);
|
||||
pruefe(name, a && a.ok === false, JSON.stringify(a).slice(0, 70));
|
||||
}
|
||||
|
||||
L(`\n===== ${ok} bestanden, ${fehler} fehlgeschlagen =====`);
|
||||
process.exitCode = fehler ? 1 : 0;
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
+1
-1
@@ -34,7 +34,7 @@
|
||||
Start alle alten Zwischenspeicher weg. Muss bei jeder Änderung an den
|
||||
Dateien unten hochgezählt werden, sonst hängen Nutzer auf einem alten
|
||||
Stand fest. */
|
||||
const CACHE_NAME = "dogfather-webdesign-v24";
|
||||
const CACHE_NAME = "dogfather-webdesign-v25";
|
||||
|
||||
/* Bausteine, die die Oberfläche zum Anzeigen braucht. Bewusst KEINE
|
||||
HTML-Datei in dieser Liste. */
|
||||
|
||||
@@ -247,6 +247,149 @@
|
||||
.vw-ub-kachel:hover:not(:disabled), .vw-ub-projekt:hover { transform: none; }
|
||||
}
|
||||
|
||||
/* ================================================================
|
||||
SUCHE ÜBER ALLES
|
||||
|
||||
Liegt über der Seite, nicht daneben: Sie ist ein Werkzeug, das man
|
||||
kurz benutzt und wieder wegtut. Ein fest eingebautes Suchfeld
|
||||
nähme dauerhaft Platz für etwas, das man zehnmal am Tag für drei
|
||||
Sekunden braucht.
|
||||
================================================================ */
|
||||
.vw-palette { position: fixed; inset: 0; z-index: 200; }
|
||||
.vw-palette[hidden] { display: none; }
|
||||
.vw-palette-hg {
|
||||
position: absolute; inset: 0;
|
||||
background: rgb(0 0 0 / .55);
|
||||
backdrop-filter: blur(3px);
|
||||
}
|
||||
.vw-palette-kasten {
|
||||
position: relative;
|
||||
/* Oben statt mittig: Die Liste wächst nach unten, und ein mittig
|
||||
zentrierter Kasten würde bei jedem Tastendruck springen. */
|
||||
margin: max(8vh, calc(1rem + var(--wd-sicher-oben, 0px))) auto 0;
|
||||
width: min(680px, calc(100vw - 1.6rem));
|
||||
max-height: min(70vh, 620px);
|
||||
display: flex; flex-direction: column;
|
||||
background: var(--wd-flaeche-2, #16191d);
|
||||
border: 1px solid rgb(var(--wd-blau-rgb) / .28);
|
||||
border-radius: var(--wd-radius-l, 20px);
|
||||
box-shadow: 0 24px 70px -20px rgb(0 0 0 / .85);
|
||||
overflow: hidden;
|
||||
}
|
||||
.vw-palette-kopf {
|
||||
display: flex; align-items: center; gap: .6rem;
|
||||
padding: .85rem 1rem;
|
||||
border-bottom: 1px solid rgb(255 255 255 / .07);
|
||||
}
|
||||
.vw-palette-lupe { color: var(--wd-text-soft); flex-shrink: 0; }
|
||||
.vw-palette-feld {
|
||||
flex: 1; min-width: 0;
|
||||
background: none; border: 0; outline: none;
|
||||
color: var(--wd-text);
|
||||
/* 16px, damit iOS beim Antippen nicht in die Seite hineinzoomt. */
|
||||
font-size: 16px; font-family: var(--font-body);
|
||||
}
|
||||
.vw-palette-feld::placeholder { color: var(--wd-text-soft); }
|
||||
.vw-palette-weg {
|
||||
flex-shrink: 0; min-height: 44px; min-width: 44px;
|
||||
background: rgb(255 255 255 / .06); border: 1px solid rgb(255 255 255 / .1);
|
||||
border-radius: 8px; color: var(--wd-text-soft);
|
||||
font-size: .74rem; font-weight: 700; cursor: pointer;
|
||||
}
|
||||
.vw-palette-weg:focus-visible { outline: 2px solid rgb(var(--wd-blau-rgb) / .95); outline-offset: 2px; }
|
||||
|
||||
/* Zwei Klassen, weil `.wd p` (Klasse + Element) sonst gewinnt.
|
||||
|
||||
Dieselbe Falle wie schon bei `.wd a`, `.wd .wd-btn--haupt` und den
|
||||
Zwischenueberschriften der Uebersicht: Die Fusszeile und der Zaehler
|
||||
erschienen in 17,9px statt 11,5px -- ein Tastaturhinweis, der so
|
||||
gross ist wie der Inhalt, den er erklaert. */
|
||||
.wd .vw-palette-zahl {
|
||||
margin: 0; padding: .35rem 1rem 0;
|
||||
font-size: .76rem; color: var(--wd-text-soft);
|
||||
}
|
||||
.wd .vw-palette-zahl:empty { display: none; }
|
||||
|
||||
.vw-palette-liste {
|
||||
list-style: none; margin: 0; padding: .35rem;
|
||||
overflow-y: auto; flex: 1;
|
||||
}
|
||||
.vw-pal-treffer {
|
||||
display: flex; align-items: center; gap: .7rem;
|
||||
padding: .6rem .65rem; border-radius: 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
/* Die Auswahl ist eine FLÄCHE, kein Rahmen: Ein Rahmen verschiebt bei
|
||||
manchen Schriftgrössen die Zeile um einen Pixel, und beim Durchgehen
|
||||
mit den Pfeiltasten zappelt dann die ganze Liste. */
|
||||
.vw-pal-treffer.vw-pal-an { background: rgb(var(--wd-blau-rgb) / .16); }
|
||||
.vw-pal-art {
|
||||
flex-shrink: 0; width: 62px; text-align: center;
|
||||
padding: .16rem 0; border-radius: 999px;
|
||||
font-size: .68rem; font-weight: 800; letter-spacing: .03em;
|
||||
text-transform: uppercase;
|
||||
background: rgb(255 255 255 / .07); color: var(--wd-text-soft);
|
||||
}
|
||||
.vw-pal-art--anfrage { background: rgb(var(--wd-gold-rgb) / .16); color: var(--wd-gold); }
|
||||
.vw-pal-art--kunde { background: rgb(var(--wd-blau-rgb) / .16); color: var(--wd-blau); }
|
||||
.vw-pal-art--projekt { background: rgb(var(--wd-lila-rgb) / .18); color: var(--wd-lila); }
|
||||
.vw-pal-art--zahlung { background: rgb(var(--wd-ok-rgb) / .16); color: var(--wd-ok); }
|
||||
|
||||
.vw-pal-mitte { flex: 1; min-width: 0; display: flex; flex-direction: column; }
|
||||
.vw-pal-titel {
|
||||
font-size: .93rem; font-weight: 650; color: var(--wd-text);
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
.vw-pal-unten {
|
||||
font-size: .78rem; color: var(--wd-text-soft);
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
.vw-pal-marke {
|
||||
flex-shrink: 0; font-size: .72rem; color: var(--wd-text-soft);
|
||||
padding: .12rem .5rem; border-radius: 999px;
|
||||
background: rgb(255 255 255 / .06);
|
||||
}
|
||||
|
||||
.wd .vw-pal-hinweis {
|
||||
padding: 1.4rem 1rem; text-align: center;
|
||||
color: var(--wd-text-soft); font-size: .9rem;
|
||||
}
|
||||
.vw-pal-hinweis--fehler { color: #e78278; }
|
||||
|
||||
.wd .vw-palette-fuss {
|
||||
margin: 0; padding: .55rem 1rem;
|
||||
border-top: 1px solid rgb(255 255 255 / .07);
|
||||
font-size: .72rem; color: var(--wd-text-soft);
|
||||
}
|
||||
.wd .vw-palette-fuss kbd {
|
||||
display: inline-block; padding: .05rem .35rem; margin: 0 .1rem;
|
||||
border: 1px solid rgb(255 255 255 / .14); border-radius: 5px;
|
||||
background: rgb(255 255 255 / .05); font-size: .9em;
|
||||
font-family: var(--font-body);
|
||||
}
|
||||
@media (max-width: 620px) {
|
||||
/* Auf dem Handy gibt es keine Tastatur mit Strg -- der Hinweis wäre
|
||||
dort eine Anleitung für etwas Unmögliches. */
|
||||
.wd .vw-palette-fuss { display: none; }
|
||||
.vw-palette-kasten { max-height: min(80vh, 620px); }
|
||||
}
|
||||
|
||||
/* Der Lupenknopf in der Kopfzeile. */
|
||||
.vw-suche-auf {
|
||||
display: inline-flex; align-items: center; justify-content: center; gap: .4rem;
|
||||
min-height: 44px; padding: 0 .8rem;
|
||||
background: rgb(255 255 255 / .05);
|
||||
border: 1px solid var(--wd-rand); border-radius: 10px;
|
||||
color: var(--wd-text-soft); cursor: pointer; font: inherit; font-size: .84rem;
|
||||
}
|
||||
.vw-suche-auf:hover { border-color: rgb(var(--wd-blau-rgb) / .4); color: var(--wd-text); }
|
||||
.vw-suche-auf:focus-visible { outline: 2px solid rgb(var(--wd-blau-rgb) / .95); outline-offset: 2px; }
|
||||
.vw-suche-auf kbd {
|
||||
padding: .05rem .3rem; border: 1px solid rgb(255 255 255 / .14);
|
||||
border-radius: 4px; font-size: .72rem; font-family: var(--font-body);
|
||||
}
|
||||
@media (max-width: 620px) { .vw-suche-auf kbd { display: none; } }
|
||||
|
||||
/* ---------- Kennzahlen, gehobene Fassung ----------
|
||||
Die erste Fassung war eine Reihe stiller Zahlen. Zahlen, die man
|
||||
nur anschauen kann, sind Dekoration. Diese hier sind Werkzeuge:
|
||||
@@ -891,6 +1034,17 @@
|
||||
class="vw-abzeichen" id="vw-pf-zaehler" hidden>0</span></button>
|
||||
</div>
|
||||
<div class="vw-wer">
|
||||
<!-- Der Lupenknopf steht neben dem Namen, nicht in der
|
||||
Reiterleiste: Suchen ist keine Ansicht, sondern ein
|
||||
Werkzeug -- es gehoert nicht zwischen die Kategorien. -->
|
||||
<button type="button" class="vw-suche-auf" id="vw-suche-auf"
|
||||
aria-label="Suche über alles öffnen">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2" aria-hidden="true">
|
||||
<circle cx="11" cy="11" r="7" /><path d="M20 20l-3.5-3.5" />
|
||||
</svg>
|
||||
Suchen <kbd>Strg K</kbd>
|
||||
</button>
|
||||
<span id="vw-name">—</span>
|
||||
<button type="button" class="wd-btn wd-btn--still wd-btn--klein" id="vw-abmelden">Abmelden</button>
|
||||
</div>
|
||||
@@ -909,6 +1063,52 @@
|
||||
2. Was wartet auf den KUNDEN? (da bin ich raus)
|
||||
3. Wie steht es ums Geld? (Umsatz, Gebühren, Offenes)
|
||||
Erst danach Projekte und Verlauf. -->
|
||||
<!-- ============ SUCHE ÜBER ALLES ============
|
||||
|
||||
Aufbau nach dem ARIA-Muster "Combobox mit Listbox-Popup"
|
||||
(W3C Authoring Practices). Das ist kein Selbstzweck: Ein
|
||||
Suchfeld, das Vorschläge einblendet, ist für einen
|
||||
Screenreader ohne diese Auszeichnung stumm — man tippt, es
|
||||
erscheinen Treffer, und angesagt wird nichts.
|
||||
|
||||
Die Rollen sind bewusst getrennt: Das EINGABEFELD behält den
|
||||
Tastaturfokus die ganze Zeit (deshalb aria-activedescendant
|
||||
statt echtem Fokuswechsel), die Liste daneben ist nur
|
||||
Anzeige. Würde der Fokus in die Liste springen, könnte man
|
||||
nicht weitertippen, um die Suche zu verfeinern. -->
|
||||
<div class="vw-palette" id="vw-palette" hidden>
|
||||
<div class="vw-palette-hg" data-palette-zu></div>
|
||||
<div class="vw-palette-kasten" role="dialog" aria-modal="true"
|
||||
aria-labelledby="vw-pal-titel">
|
||||
<h2 id="vw-pal-titel" class="wd-visuell-versteckt">Suche über alles</h2>
|
||||
<div class="vw-palette-kopf">
|
||||
<svg class="vw-palette-lupe" width="18" height="18" viewBox="0 0 24 24"
|
||||
fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
|
||||
<circle cx="11" cy="11" r="7" /><path d="M20 20l-3.5-3.5" />
|
||||
</svg>
|
||||
<input type="text" id="vw-pal-feld" class="vw-palette-feld"
|
||||
role="combobox" aria-expanded="false" aria-controls="vw-pal-liste"
|
||||
aria-autocomplete="list" aria-label="Nach Anfragen, Kunden, Projekten oder Zahlungen suchen"
|
||||
placeholder="Name, Nummer, E-Mail …" autocomplete="off" spellcheck="false" />
|
||||
<button type="button" class="vw-palette-weg" data-palette-zu
|
||||
aria-label="Suche schließen">Esc</button>
|
||||
</div>
|
||||
|
||||
<!-- Der Zähler wird angesagt, ohne den Fokus zu stören.
|
||||
aria-live="polite": Wer gerade tippt, wird nicht
|
||||
unterbrochen, hört die Zahl aber nach dem Tippen. -->
|
||||
<p class="vw-palette-zahl" id="vw-pal-zahl" role="status" aria-live="polite"></p>
|
||||
|
||||
<ul class="vw-palette-liste" id="vw-pal-liste" role="listbox"
|
||||
aria-label="Suchergebnisse"></ul>
|
||||
|
||||
<p class="vw-palette-fuss">
|
||||
<kbd>↑</kbd><kbd>↓</kbd> auswählen · <kbd>Enter</kbd> öffnen ·
|
||||
<kbd>Esc</kbd> schließen · <kbd>Strg</kbd>+<kbd>K</kbd> von überall
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="vw-uebersicht-bereich">
|
||||
<div class="vw-laedt" id="vw-ub-laedt">Wird geladen…</div>
|
||||
<div id="vw-ub-inhalt" hidden>
|
||||
@@ -2245,6 +2445,232 @@
|
||||
postfach: { titel: "Postfach", kasten: "vw-postfach-bereich" },
|
||||
};
|
||||
|
||||
/* =====================================================================
|
||||
SUCHE ÜBER ALLES ("Befehlspalette")
|
||||
|
||||
Bisher gab es ein Suchfeld, und es durchsuchte nur die Anfragen. Wer
|
||||
einen Namen im Kopf hatte, musste raten, in welchem Reiter er
|
||||
nachsehen muss. Jetzt: Strg+K von überall, auf dem Handy der
|
||||
Lupenknopf oben.
|
||||
|
||||
VIER DINGE, DIE MAN LEICHT FALSCH MACHT
|
||||
|
||||
1. BEI JEDEM TASTENDRUCK SUCHEN. Wer "Musterbau" tippt, löst neun
|
||||
Abfragen aus, von denen acht schon veraltet sind, bevor sie
|
||||
ankommen. Deshalb eine kurze Wartezeit -- lang genug, um Tippen
|
||||
zu erkennen, kurz genug, um nicht zu bremsen.
|
||||
|
||||
2. DAS WETTRENNEN DER ANTWORTEN. Auch mit Wartezeit können zwei
|
||||
Abfragen unterwegs sein. Kommt die ältere später an, überschreibt
|
||||
sie die neuere -- man tippt weiter und sieht plötzlich wieder die
|
||||
Treffer von vorhin. Deshalb bekommt jede Abfrage eine laufende
|
||||
Nummer, und nur die jeweils neueste darf zeichnen.
|
||||
|
||||
3. DEN FOKUS IN DIE LISTE VERSCHIEBEN. Naheliegend, aber falsch: Dann
|
||||
kann man nicht mehr weitertippen. Der Fokus bleibt im Eingabefeld,
|
||||
die Auswahl wandert über aria-activedescendant -- so schreibt es
|
||||
das ARIA-Muster vor, und so verhält es sich auch richtig.
|
||||
|
||||
4. NUR DEN GUTEN FALL BAUEN. Leer, lädt und Fehler sind eigene
|
||||
Zustände. Ein Kasten, der bei einem Serverfehler einfach leer
|
||||
bleibt, sieht aus wie "nichts gefunden" -- und man sucht dann an
|
||||
der falschen Stelle nach dem Problem.
|
||||
===================================================================== */
|
||||
var palette = document.getElementById("vw-palette");
|
||||
var palFeld = document.getElementById("vw-pal-feld");
|
||||
var palListe = document.getElementById("vw-pal-liste");
|
||||
var palZahl = document.getElementById("vw-pal-zahl");
|
||||
|
||||
var palTreffer = [];
|
||||
var palAuswahl = -1;
|
||||
var palLauf = 0; // laufende Nummer gegen das Wettrennen
|
||||
var palZeitgeber = null;
|
||||
var palVorherFokus = null; // wohin der Fokus zurückkehrt
|
||||
|
||||
var ART_TEXT = {
|
||||
anfrage: "Anfrage", kunde: "Kunde", projekt: "Projekt", zahlung: "Zahlung",
|
||||
};
|
||||
|
||||
function paletteAuf() {
|
||||
if (!palette.hidden) return;
|
||||
/* Merken, wo der Fokus war. Beim Schliessen muss er dorthin zurueck --
|
||||
sonst steht man nach Esc am Seitenanfang und muss sich neu
|
||||
orientieren. */
|
||||
palVorherFokus = document.activeElement;
|
||||
palette.hidden = false;
|
||||
palFeld.value = "";
|
||||
palListeLeeren();
|
||||
palZahl.textContent = "";
|
||||
palFeld.focus();
|
||||
document.body.style.overflow = "hidden"; // Hintergrund nicht mitscrollen
|
||||
}
|
||||
|
||||
function paletteZu() {
|
||||
if (palette.hidden) return;
|
||||
palette.hidden = true;
|
||||
document.body.style.overflow = "";
|
||||
palFeld.setAttribute("aria-expanded", "false");
|
||||
palFeld.removeAttribute("aria-activedescendant");
|
||||
if (palVorherFokus && palVorherFokus.focus) {
|
||||
try { palVorherFokus.focus(); } catch (e) { /* Element ist weg, egal */ }
|
||||
}
|
||||
}
|
||||
|
||||
function palListeLeeren() {
|
||||
palListe.innerHTML = "";
|
||||
palTreffer = [];
|
||||
palAuswahl = -1;
|
||||
palFeld.setAttribute("aria-expanded", "false");
|
||||
palFeld.removeAttribute("aria-activedescendant");
|
||||
}
|
||||
|
||||
function palHinweis(text, art) {
|
||||
palListe.innerHTML = '<li class="vw-pal-hinweis' + (art ? " vw-pal-hinweis--" + art : "") +
|
||||
'" role="presentation">' + s(text) + "</li>";
|
||||
palTreffer = [];
|
||||
palAuswahl = -1;
|
||||
palFeld.setAttribute("aria-expanded", "false");
|
||||
palFeld.removeAttribute("aria-activedescendant");
|
||||
}
|
||||
|
||||
function palZeichnen(d) {
|
||||
palTreffer = d.treffer || [];
|
||||
palAuswahl = -1;
|
||||
|
||||
if (d.zuKurz) {
|
||||
palHinweis("Mindestens zwei Zeichen eingeben.");
|
||||
palZahl.textContent = "";
|
||||
return;
|
||||
}
|
||||
if (!palTreffer.length) {
|
||||
palHinweis("Nichts gefunden zu „" + d.begriff + "“.");
|
||||
palZahl.textContent = "Keine Treffer";
|
||||
return;
|
||||
}
|
||||
|
||||
palListe.innerHTML = palTreffer.map(function (t, i) {
|
||||
return '<li class="vw-pal-treffer" role="option" id="vw-pal-o' + i + '"' +
|
||||
' aria-selected="false" data-nr="' + i + '">' +
|
||||
'<span class="vw-pal-art vw-pal-art--' + s(t.art) + '">' +
|
||||
s(ART_TEXT[t.art] || t.art) + "</span>" +
|
||||
'<span class="vw-pal-mitte">' +
|
||||
'<span class="vw-pal-titel">' + s(t.titel || "—") + "</span>" +
|
||||
'<span class="vw-pal-unten">' + s(t.unten || "") + "</span>" +
|
||||
"</span>" +
|
||||
(t.marke ? '<span class="vw-pal-marke">' + s(t.marke) + "</span>" : "") +
|
||||
"</li>";
|
||||
}).join("");
|
||||
|
||||
palFeld.setAttribute("aria-expanded", "true");
|
||||
palZahl.textContent = palTreffer.length + (palTreffer.length === 1 ? " Treffer" : " Treffer")
|
||||
+ (d.begrenzt ? " (je Bereich höchstens " + d.grenzeJeBereich + ")" : "");
|
||||
palWaehlen(0);
|
||||
}
|
||||
|
||||
function palWaehlen(nr) {
|
||||
var knoten = palListe.querySelectorAll('[role="option"]');
|
||||
if (!knoten.length) return;
|
||||
if (nr < 0) nr = knoten.length - 1;
|
||||
if (nr >= knoten.length) nr = 0;
|
||||
palAuswahl = nr;
|
||||
knoten.forEach(function (k, i) {
|
||||
k.setAttribute("aria-selected", i === nr ? "true" : "false");
|
||||
k.classList.toggle("vw-pal-an", i === nr);
|
||||
});
|
||||
/* aria-activedescendant statt echtem Fokus: Der Fokus bleibt im
|
||||
Eingabefeld, damit man weitertippen kann -- angesagt wird
|
||||
trotzdem die ausgewaehlte Zeile. */
|
||||
palFeld.setAttribute("aria-activedescendant", knoten[nr].id);
|
||||
knoten[nr].scrollIntoView({ block: "nearest" });
|
||||
}
|
||||
|
||||
function palOeffnen(nr) {
|
||||
var t = palTreffer[nr];
|
||||
if (!t) return;
|
||||
paletteZu();
|
||||
var knopf = document.querySelector('[data-tab="' + t.ziel + '"]');
|
||||
if (knopf) knopf.click();
|
||||
/* Bei einer Anfrage direkt die Detailansicht oeffnen. Nur in den
|
||||
Reiter zu springen waere die halbe Strecke -- man muesste den
|
||||
Treffer in der Liste noch einmal suchen, den man gerade
|
||||
ausgewaehlt hat. */
|
||||
if (t.art === "anfrage" && typeof zeigeDetail === "function") {
|
||||
setTimeout(function () { zeigeDetail(t.id); }, 260);
|
||||
}
|
||||
}
|
||||
|
||||
async function palSuchen(begriff) {
|
||||
var meine = ++palLauf;
|
||||
if (!begriff || begriff.trim().length < 2) {
|
||||
palHinweis("Mindestens zwei Zeichen eingeben.");
|
||||
palZahl.textContent = "";
|
||||
return;
|
||||
}
|
||||
palHinweis("Wird gesucht…", "laedt");
|
||||
try {
|
||||
var d = await api("/webdesign/admin/suche?q=" + encodeURIComponent(begriff));
|
||||
if (meine !== palLauf) return; // eine neuere Abfrage ist schon unterwegs
|
||||
palZeichnen(d);
|
||||
} catch (fehler) {
|
||||
if (meine !== palLauf) return;
|
||||
/* Ein Fehler darf NICHT wie "nichts gefunden" aussehen. */
|
||||
palHinweis("Die Suche ist fehlgeschlagen: " + (fehler.message || "unbekannt"), "fehler");
|
||||
palZahl.textContent = "";
|
||||
}
|
||||
}
|
||||
|
||||
if (palette) {
|
||||
palFeld.addEventListener("input", function () {
|
||||
clearTimeout(palZeitgeber);
|
||||
var wert = palFeld.value;
|
||||
/* 180 ms: kurz genug, dass es sofort wirkt, lang genug, dass beim
|
||||
Durchtippen eines Namens nicht jede Taste eine Abfrage ausloest. */
|
||||
palZeitgeber = setTimeout(function () { palSuchen(wert); }, 180);
|
||||
});
|
||||
|
||||
palFeld.addEventListener("keydown", function (e) {
|
||||
if (e.key === "ArrowDown") { e.preventDefault(); palWaehlen(palAuswahl + 1); }
|
||||
else if (e.key === "ArrowUp") { e.preventDefault(); palWaehlen(palAuswahl - 1); }
|
||||
else if (e.key === "Enter") { e.preventDefault(); palOeffnen(palAuswahl); }
|
||||
else if (e.key === "Escape") { e.preventDefault(); paletteZu(); }
|
||||
else if (e.key === "Home" && palTreffer.length) { e.preventDefault(); palWaehlen(0); }
|
||||
else if (e.key === "End" && palTreffer.length) { e.preventDefault(); palWaehlen(palTreffer.length - 1); }
|
||||
});
|
||||
|
||||
palListe.addEventListener("click", function (e) {
|
||||
var zeile = e.target.closest ? e.target.closest("[data-nr]") : null;
|
||||
if (zeile) palOeffnen(Number(zeile.dataset.nr));
|
||||
});
|
||||
/* Mit der Maus darueberfahren waehlt mit aus -- sonst zeigt die
|
||||
Tastaturauswahl auf eine andere Zeile als die, die man anklickt. */
|
||||
palListe.addEventListener("mousemove", function (e) {
|
||||
var zeile = e.target.closest ? e.target.closest("[data-nr]") : null;
|
||||
if (zeile && Number(zeile.dataset.nr) !== palAuswahl) palWaehlen(Number(zeile.dataset.nr));
|
||||
});
|
||||
|
||||
document.querySelectorAll("[data-palette-zu]").forEach(function (k) {
|
||||
k.addEventListener("click", paletteZu);
|
||||
});
|
||||
|
||||
/* Strg+K bzw. Cmd+K von ueberall.
|
||||
|
||||
Nicht abfangen, waehrend in einem anderen Feld getippt wird? Doch --
|
||||
gerade dann ist es nuetzlich. Die Tastenkombination ist in Browsern
|
||||
mit der Adressleiste belegt, deshalb preventDefault; das ist bei
|
||||
dieser Kombination die uebliche Erwartung in Verwaltungsoberflaechen. */
|
||||
document.addEventListener("keydown", function (e) {
|
||||
if ((e.ctrlKey || e.metaKey) && (e.key === "k" || e.key === "K")) {
|
||||
e.preventDefault();
|
||||
palette.hidden ? paletteAuf() : paletteZu();
|
||||
}
|
||||
/* Esc schliesst auch, wenn der Fokus schon woanders liegt. */
|
||||
if (e.key === "Escape" && !palette.hidden) paletteZu();
|
||||
});
|
||||
|
||||
var lupe = document.getElementById("vw-suche-auf");
|
||||
if (lupe) lupe.addEventListener("click", paletteAuf);
|
||||
}
|
||||
|
||||
var aktiverTab = "uebersicht";
|
||||
document.getElementById("vw-titel").textContent = ANSICHTEN[aktiverTab].titel;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user