workers: Bot-Leads Fokus beim Blättern ohne Scrollen und ohne Fokusdiebstahl (N5), „Erneut versuchen“ nach einem Fehler (N6)

N5: Der Fokus geht nach dem Blättern über restoreFocus (workersFocus.js) mit preventScroll an die Seitenzeile zurück, aber nur, wenn er
wirklich verloren ging (body, nirgends, alter Knopf ausgehängt). Hat der Nutzer inzwischen Suchfeld oder Raumfilter angesteuert, bleibt er dort.
N6: Scheitert eine Anfrage, steht unter der Meldung „Erneut versuchen“; der Knopf stellt genau diese Anfrage (Raum, Suche, Seite oder Eintrag)
noch einmal und die Seitenzeile bekommt danach den Fokus zurück. CSS: .wk-bl-retry, am Handy 40 px.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-20 16:15:43 +00:00
1 parent f80b831b6a
commit 453d3585ba
4 files changed
+207 -24

No files matched your search

+179 -11
View File
@@ -36,20 +36,47 @@ function fakeApi() {
return { calls, botleads: (params) => new Promise((resolve, reject) => { calls.push({ params, resolve, reject }) }) }
}
// DOM-Attrappe für den Fokus (Muster workersPultUi.test.mjs): ref an select und den Blätterknöpfen liefert __fakeRef. Ein Element zählt den Aufruf von focus() nur,
// solange es im Baum steht; React setzt ref.current beim Abbau auf null, dann wäre ref.current?.focus() wirkungslos. Der Schlüssel ist „select“ oder die Beschriftung des Knopfs.
// DOM-Attrappe für den Fokus (Muster workersPultUi.test.mjs): document mit body und activeElement, ref an select und den Blätterknöpfen liefert __fakeRef.
// Der Schlüssel ist „select“ oder die Beschriftung des Knopfs. Ein Element gilt nur, solange es im Baum steht (isConnected, wie im Browser); steht es beim
// nächsten Render nicht im Baum der Runde davor (Seitenzeile während des Ladens), ist es danach ein neues Element, so wie React die Knöpfe neu einhängt.
// focus() zählt nur für ein eingehängtes Element, merkt sich Schlüssel und Argument und setzt activeElement (Fokus liegt dann auf dem Element).
function fakeFocus() {
const f = { focused: [], tree: () => null, made: new Map() }
const body = { name: 'body' }
const doc = { body, documentElement: { name: 'html' }, activeElement: body }
const f = { doc, body, focused: [], options: [], tree: () => null, view: null, made: new Map() }
const keyOf = (tag, props) => (tag === 'button' ? String(props.children) : tag)
const live = (tag, key) => nodes(f.tree(), (n) => n.tag === tag && keyOf(tag, n.props) === key).length > 0
const had = Object.hasOwn(globalThis, 'document')
const saved = globalThis.document
globalThis.document = doc
globalThis.__fakeRef = (tag, props) => {
const key = keyOf(tag, props)
if (!f.made.has(key)) f.made.set(key, { key, props, get disabled() { return this.props.disabled === true }, focus() { if (live(tag, key)) f.focused.push(key) } })
const known = f.made.get(key)
if (!known || !live(tag, key)) { // während dieser Runde zeigt f.tree() noch die Runde davor
f.made.set(key, {
key, props,
get disabled() { return this.props.disabled === true },
get isConnected() { return f.made.get(key) === this && live(tag, key) },
focus(options) { if (this.isConnected) { f.focused.push(key); f.options.push(options); doc.activeElement = this } },
})
}
f.made.get(key).props = props
return f.made.get(key)
}
f.attach = (view) => { f.tree = () => view.tree() }
f.restore = () => { delete globalThis.__fakeRef }
// ein Element, das der Nutzer von Hand ansteuert (Suchfeld, Link …): eingehängt, bedienbar
f.other = (name) => ({ name, isConnected: true, disabled: false, focus() { doc.activeElement = this } })
f.attach = (view) => { f.view = view; f.tree = () => view.tree() }
// Der Nutzer drückt einen Knopf: der Browser gibt ihm den Fokus, dann läuft der Klick
// (Knöpfe ohne ref, z. B. „Erneut versuchen“, sind nur so lange Element, wie ein Knopf dieser Beschriftung im Baum steht)
f.press = async (label) => {
doc.activeElement = f.made.get(label) ?? { name: label, disabled: false, get isConnected() { return buttons(f.tree(), label).length > 0 }, focus() { doc.activeElement = this } }
await f.view.click(label)
}
f.restore = () => {
if (had) globalThis.document = saved
else delete globalThis.document
delete globalThis.__fakeRef
}
return f
}
@@ -344,7 +371,7 @@ test('nach dem Abbau setzt eine späte Antwort nichts mehr, ein offener Zeitgebe
assert.equal(ui.lateUpdates(), 0)
})
test('Blättern (C5): nach dem Laden der neuen Seite bekommt die Seitenzeile den Tastaturfokus zurück; ist der Knopf gesperrt, der andere', async () => {
test('Blättern (C5): nach dem Laden der neuen Seite bekommt die Seitenzeile den Tastaturfokus zurück, ohne dass die Seite scrollt (N5); ist der Knopf gesperrt, der andere', async () => {
const api = fakeApi()
const dom = fakeFocus()
const ui = await start(api)
@@ -352,19 +379,63 @@ test('Blättern (C5): nach dem Laden der neuen Seite bekommt die Seitenzeile den
try {
await ui.answer(0, { items: [ITEM], total: 120, pageSize: 50 })
assert.deepEqual(dom.focused, []) // nicht schon beim ersten Laden
await ui.click('Weiter')
await dom.press('Weiter')
assert.deepEqual(dom.focused, []) // während der Anfrage ist die Seitenzeile weg
assert.equal(dom.doc.activeElement.isConnected, false) // und mit ihr der Knopf, der den Fokus hatte: der Fokus ist verloren
await ui.answer(1, { items: [ITEM], total: 120, pageSize: 50 }) // Seite 2 von 3
assert.deepEqual(dom.focused, ['Weiter'])
await ui.click('Weiter')
await dom.press('Weiter')
await ui.answer(2, { items: [ITEM], total: 120, pageSize: 50 }) // Seite 3 von 3: „Weiter“ ist gesperrt ⇒ „Zurück“
assert.deepEqual(dom.focused, ['Weiter', 'Zurück'])
await ui.click('Zurück')
await dom.press('Zurück')
await ui.answer(3, { items: [ITEM], total: 120, pageSize: 50 }) // Seite 2
assert.deepEqual(dom.focused, ['Weiter', 'Zurück', 'Zurück'])
await ui.click('Zurück')
await dom.press('Zurück')
await ui.answer(4, { items: [ITEM], total: 120, pageSize: 50 }) // Seite 1: „Zurück“ ist gesperrt ⇒ „Weiter“
assert.deepEqual(dom.focused, ['Weiter', 'Zurück', 'Zurück', 'Weiter'])
// N5: ohne { preventScroll: true } sprängen Chromium und Firefox ans Ende der neuen Seite (der Knopf steht unter der Tabelle)
assert.deepEqual(dom.options, [{ preventScroll: true }, { preventScroll: true }, { preventScroll: true }, { preventScroll: true }])
} finally { dom.restore(); ui.end() }
})
test('Blättern (N5): hat der Nutzer den Fokus inzwischen selbst woandershin gesetzt, wird er nicht gestohlen; liegt er auf body oder nirgends, kommt er zurück', async () => {
const api = fakeApi()
const dom = fakeFocus()
const ui = await start(api)
dom.attach(ui)
try {
await ui.answer(0, { items: [ITEM], total: 400, pageSize: 50 })
// 1. Der Nutzer klickt während des Ladens ins Suchfeld (Fokus dort, aber die Zeitgeber der Suche laufen noch nicht)
await dom.press('Weiter')
const search = dom.other('Suchfeld')
search.focus()
await ui.answer(1, { items: [ITEM], total: 400, pageSize: 50 })
assert.deepEqual(dom.focused, [])
assert.equal(dom.doc.activeElement, search)
// 2. … oder er wählt den Raumfilter mit der Tastatur an, ohne etwas zu ändern
await dom.press('Weiter')
dom.made.get('select').focus()
assert.deepEqual(dom.focused, ['select'])
await ui.answer(2, { items: [ITEM], total: 400, pageSize: 50 })
assert.deepEqual(dom.focused, ['select']) // kein weiterer Fokus auf die Seitenzeile
assert.equal(dom.doc.activeElement.key, 'select')
// 3. Fokus auf body: zurück an die Seitenzeile
dom.focused.length = 0; dom.options.length = 0
await dom.press('Weiter')
dom.doc.activeElement = dom.body
await ui.answer(3, { items: [ITEM], total: 400, pageSize: 50 })
assert.deepEqual(dom.focused, ['Weiter'])
assert.deepEqual(dom.options, [{ preventScroll: true }])
// 4. Fokus auf nichts (activeElement null, etwa ein Dokument ohne Fokus): ebenfalls zurück
await dom.press('Weiter')
dom.doc.activeElement = null
await ui.answer(4, { items: [ITEM], total: 400, pageSize: 50 })
assert.deepEqual(dom.focused, ['Weiter', 'Weiter'])
// 5. ohne document (Server, Node): nichts geschieht, nichts bricht
delete globalThis.document
await ui.click('Weiter')
await ui.answer(5, { items: [ITEM], total: 400, pageSize: 50 })
assert.deepEqual(dom.focused, ['Weiter', 'Weiter'])
} finally { dom.restore(); ui.end() }
})
@@ -398,6 +469,103 @@ test('Blättern (C5): der Fokus springt nicht, wenn der Nutzer inzwischen Raum o
} finally { dom.restore(); ui.end() }
})
test('Fehler beim Blättern (N6): „Erneut versuchen“ wiederholt genau die Seitenanfrage; Fehler und Knopf verschwinden, die Seitenzeile ist wieder da', async () => {
const api = fakeApi()
const ui = await start(api)
try {
await ui.answer(0, { items: [ITEM], total: 120, pageSize: 50 })
await ui.click('Weiter')
assert.deepEqual(api.calls[1].params, { room: '', q: '', page: 2 })
assert.equal(buttons(ui.tree(), 'Erneut versuchen').length, 0) // beim Laden gibt es keinen Knopf
await ui.fail(1, Object.assign(new Error('Der Hustler-Dienst ist gerade nicht erreichbar.'), { status: 0 }))
assert.deepEqual(ui.errors(), [{ text: 'Der Hustler-Dienst ist gerade nicht erreichbar.', role: 'alert' }]) // der Satz bleibt unverändert im Alarm
const retry = buttons(ui.tree(), 'Erneut versuchen')
assert.equal(retry.length, 1)
assert.equal(retry[0].props.type, 'button')
assert.equal(retry[0].props.disabled, undefined)
assert.equal(api.calls.length, 2)
await ui.click('Erneut versuchen')
assert.equal(api.calls.length, 3) // genau eine neue Anfrage, dieselbe Seite
assert.deepEqual(api.calls[2].params, { room: '', q: '', page: 2 })
assert.ok(ui.text().includes('Wird geladen'))
assert.deepEqual(ui.errors(), [])
assert.equal(buttons(ui.tree(), 'Erneut versuchen').length, 0)
await ui.answer(2, { items: [PRAXIS], total: 120, pageSize: 50 })
assert.equal(api.calls.length, 3)
assert.deepEqual(ui.errors(), [])
assert.deepEqual(ui.rows().map((r) => ui.cells(r)[0]), ['Praxis Dr. Muster67655 Kaiserslautern'])
assert.ok(ui.text().includes('Seite 2 von 3 · 120 Einträge'))
assert.deepEqual([ui.disabled('Zurück'), ui.disabled('Weiter')], [[false], [false]])
assert.equal(buttons(ui.tree(), 'Erneut versuchen').length, 0)
} finally { ui.end() }
})
test('Fehler beim Blättern (N6): scheitert auch der zweite Versuch, bleibt der Knopf; Raumfilter und Suche gehen weiterhin, mit Seite 1', async () => {
const api = fakeApi()
const ui = await start(api)
try {
await ui.answer(0, { items: [ITEM], total: 120, pageSize: 50 })
await ui.click('Weiter')
await ui.fail(1, new Error('zu langsam'))
await ui.click('Erneut versuchen')
await ui.fail(2, new Error('immer noch nicht'))
assert.deepEqual(ui.errors().map((e) => e.text), ['immer noch nicht'])
assert.equal(buttons(ui.tree(), 'Erneut versuchen').length, 1)
await ui.click('Erneut versuchen')
assert.deepEqual(api.calls[3].params, { room: '', q: '', page: 2 })
// statt eines weiteren Versuchs ändert der Nutzer den Raum: neue Anfrage mit Seite 1, die späte Antwort des Versuchs zählt nicht
await ui.pick('handwerk')
assert.deepEqual(api.calls[4].params, { room: 'handwerk', q: '', page: 1 })
await ui.answer(3, { items: [PRAXIS], total: 120, pageSize: 50 })
assert.equal(ui.rows().length, 0)
await ui.answer(4, { items: [ITEM], total: 1, pageSize: 50 })
assert.deepEqual(ui.errors(), [])
assert.deepEqual(ui.rows().map((r) => ui.cells(r)[0]), ['Elektro Beispiel GmbH67655 Kaiserslautern'])
assert.equal(buttons(ui.tree(), 'Erneut versuchen').length, 0)
} finally { ui.end() }
})
test('Fehler beim Blättern (N6): „Erneut versuchen“ gibt es auch beim ersten Laden und in der Einzelansicht und wiederholt dort die jeweilige Anfrage', async () => {
const api = fakeApi()
const ui = await start(api)
try {
await ui.fail(0, new Error('Der Hustler-Dienst ist gerade nicht erreichbar.'))
await ui.click('Erneut versuchen')
assert.deepEqual(api.calls[1].params, { room: '', q: '', page: 1 })
await ui.answer(1, { items: [ITEM], total: 1, pageSize: 50 })
assert.equal(ui.rows().length, 1)
} finally { ui.end() }
const api2 = fakeApi()
const one = await start(api2, { bot: 'p1', onClearBot: () => {} })
try {
await one.fail(0, Object.assign(new Error('Der Hustler-Dienst ist gerade nicht erreichbar.'), { status: 0 }))
assert.equal(buttons(one.tree(), 'Alle anzeigen').length, 1) // der Weg zurück bleibt offen
await one.click('Erneut versuchen')
assert.deepEqual(api2.calls.map((c) => c.params), [{ id: 'p1' }, { id: 'p1' }])
await one.answer(1, { items: [ITEM], total: 1 })
assert.equal(one.rows().length, 1)
assert.equal(one.rows()[0].props['aria-current'], 'true')
} finally { one.end() }
})
test('Fehler beim Blättern (N6): nach einem gelungenen Wiederholen bekommt die Seitenzeile den Fokus zurück (ohne Scrollen), wenn zuvor geblättert wurde', async () => {
const api = fakeApi()
const dom = fakeFocus()
const ui = await start(api)
dom.attach(ui)
try {
await ui.answer(0, { items: [ITEM], total: 120, pageSize: 50 })
await dom.press('Weiter')
await ui.fail(1, new Error('Der Hustler-Dienst ist gerade nicht erreichbar.'))
assert.deepEqual(dom.focused, []) // keine Seitenzeile, nichts zu fokussieren
await dom.press('Erneut versuchen') // der Knopf verschwindet beim Laden wie vorher die Seitenzeile
assert.deepEqual(dom.focused, [])
await ui.answer(2, { items: [ITEM], total: 120, pageSize: 50 })
assert.deepEqual(dom.focused, ['Weiter'])
assert.deepEqual(dom.options, [{ preventScroll: true }])
} finally { dom.restore(); ui.end() }
})
test('Alle anzeigen (W1): nach dem Verlassen der Einzelansicht bekommt der Raumfilter den Fokus, nicht beim Einhängen und nicht bei einem anderen Weg zur Liste', async () => {
const api = fakeApi()
const dom = fakeFocus()
+2 -2
View File
@@ -72,10 +72,10 @@ test('workers.css: gekürzte Zeilen der Bot-Leads bleiben lesbar (Text >= 4,5:1
}
})
test('workers.css: am Handy sind die Blätterknöpfe und „Alle anzeigen“ der Bot-Leads 40 px hoch, ohne die globale .btn-Regel zu ändern (C5, W1)', () => {
test('workers.css: am Handy sind die Blätterknöpfe, „Alle anzeigen“ und „Erneut versuchen“ der Bot-Leads 40 px hoch, ohne die globale .btn-Regel zu ändern (C5, W1)', () => {
const css = stripped('workers.css')
const phone = css.slice(css.indexOf('@media (max-width: 860px)'))
for (const selector of ['.wk-bl-pager .btn', '.wk-bl-single .btn']) {
for (const selector of ['.wk-bl-pager .btn', '.wk-bl-single .btn', '.wk-bl-retry .btn']) {
assert.equal(decl(body(phone, selector), 'min-height'), '40px', selector)
// min-height schlägt height: 32px der globalen .btn-Regel, und die Spezifität (0,2,0) schlägt .btn (0,1,0): kein !important nötig
assert.ok(beats(specificity(selector), specificity('.btn')), selector)