diff --git a/scripts/workers-harness/shot.cjs b/scripts/workers-harness/shot.cjs index 4626c2e..2b45426 100644 --- a/scripts/workers-harness/shot.cjs +++ b/scripts/workers-harness/shot.cjs @@ -2006,6 +2006,7 @@ async function botleads(browser, base) { await blSettled(page) check('botleads: „Alle anzeigen“ führt zur Liste (50 Zeilen, Raumfilter und Seitenzeile, kein Einzelhinweis)', (await blRows(page).count()) === 50 && (await page.locator('.wk-bl-single').count()) === 0 && (await page.getByLabel('Raum', { exact: true }).count()) === 1 && (await blFootLabel(page)) === BL_LABEL(1, 2, 60) && (await page.locator('tr[aria-current]').count()) === 0) metrics.botleadsFocusAfterAlleAnzeigen = await blFocus(page) + check('botleads: nach „Alle anzeigen“ liegt der Tastaturfokus auf dem Raumfilter (nicht auf )', metrics.botleadsFocusAfterAlleAnzeigen.tag === 'SELECT' && !metrics.botleadsFocusAfterAlleAnzeigen.onBody, metrics.botleadsFocusAfterAlleAnzeigen) await topOfPage(page) await shoot(page, 'botleads-nach-alle-anzeigen.png') diff --git a/src/components/workers/BotLeadList.jsx b/src/components/workers/BotLeadList.jsx index b88cf98..c8d3165 100644 --- a/src/components/workers/BotLeadList.jsx +++ b/src/components/workers/BotLeadList.jsx @@ -21,6 +21,13 @@ export default function BotLeadList({ api, bot, onClearBot }) { const [result, setResult] = useState({ key: null, items: [], total: 0, pageSize: undefined, error: null }) const timerRef = useRef(null) const seqRef = useRef(0) + // Tastaturfokus: Wer „Alle anzeigen“ oder „Zurück“/„Weiter“ drückt, verliert den Knopf mit dem Fokus (Einzelansicht und Seitenzeile werden ausgetauscht oder + // hängen beim Laden aus); ohne Zutun fiele der Fokus auf , an den Seitenanfang. Gemerkt wird nur, was der Nutzer als Letztes bedient hat. + const selectRef = useRef(null) // Raumfilter: Ziel nach „Alle anzeigen“ + const clearedRef = useRef(false) // „Alle anzeigen“ gedrückt: erst dann (nie schon beim Einhängen, das öffnete am Handy die Auswahl) bekommt der Raumfilter den Fokus + const prevRef = useRef(null) + const nextRef = useRef(null) + const pagedRef = useRef('') // 'prev' oder 'next': welcher Blätterknopf zuletzt gedrückt wurde; nach dem Laden bekommt die Seitenzeile den Fokus zurück const key = JSON.stringify(single ? [botId] : [view.room, view.q, view.page]) @@ -53,18 +60,49 @@ export default function BotLeadList({ api, bot, onClearBot }) { useEffect(() => () => window.clearTimeout(timerRef.current), []) + const onClear = () => { + clearedRef.current = true + onClearBot() + } + const onPage = (dir) => { + pagedRef.current = dir + setView((v) => ({ ...v, page: v.page + (dir === 'next' ? 1 : -1) })) + } const onRoom = (e) => { const room = e.target.value + pagedRef.current = '' // wer den Raum wählt, hat den Blätterknopf verlassen: der Fokus springt nicht zurück setView((v) => ({ ...v, room, page: 1 })) } const onSearch = (e) => { const value = e.target.value + pagedRef.current = '' setDraft(value) window.clearTimeout(timerRef.current) timerRef.current = window.setTimeout(() => setView((v) => ({ ...v, q: value.trim(), page: 1 })), SEARCH_DELAY_MS) } const loading = result.key !== key + + // Die Einzelansicht ist weg (die Seite hat bot aus der Adresse genommen): der Raumfilter ersetzt den gedrückten Knopf und bekommt den Fokus. + // Ohne den Knopf (Zurück-Taste des Browsers) bleibt der Fokus, wo er ist. + useEffect(() => { + const wanted = clearedRef.current + clearedRef.current = false + if (wanted && !single) selectRef.current?.focus() + }, [single]) + + // Die neue Seite ist da und die Seitenzeile wieder eingehängt: der Fokus geht an den gedrückten Knopf, ist er gesperrt (erste oder letzte Seite) an den anderen. + // Scheiterte die Anfrage, gibt es keine Seitenzeile (die Refs sind null) und nichts geschieht. + useEffect(() => { + if (loading) return + const dir = pagedRef.current + pagedRef.current = '' + if (dir === '') return + const [first, second] = dir === 'next' ? [nextRef.current, prevRef.current] : [prevRef.current, nextRef.current] + const target = first && !first.disabled ? first : second + target?.focus() + }, [loading]) + const rows = result.items.map((item) => botleadRow(item)) const info = pageInfo({ total: result.total, page: view.page, pageSize: result.pageSize }) const filtered = view.room !== '' || view.q !== '' @@ -86,8 +124,8 @@ export default function BotLeadList({ api, bot, onClearBot }) { {COLUMNS.map((name) => {name})} - {rows.map((row) => ( - + {rows.map((row, index) => ( +
{row.title}{row.gekuerzt && gekürzt}
{row.place &&
{row.place}
} @@ -116,11 +154,11 @@ export default function BotLeadList({ api, bot, onClearBot }) { {single ? (
Einzelner Eintrag aus dem Ereignisstrom - +
) : (
- {ROOM_FILTERS.map((f) => )} @@ -132,8 +170,8 @@ export default function BotLeadList({ api, bot, onClearBot }) { {info.label} {info.pages > 1 && ( - - + + )}
diff --git a/tests/unit/botleadList.test.mjs b/tests/unit/botleadList.test.mjs index cd5baac..d1814db 100644 --- a/tests/unit/botleadList.test.mjs +++ b/tests/unit/botleadList.test.mjs @@ -36,6 +36,23 @@ 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. +function fakeFocus() { + const f = { focused: [], tree: () => 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 + 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) } }) + f.made.get(key).props = props + return f.made.get(key) + } + f.attach = (view) => { f.tree = () => view.tree() } + f.restore = () => { delete globalThis.__fakeRef } + return f +} + const ITEM = { id: 'p1', room: 'handwerk', roomLabel: 'Handwerk', name: 'Elektro Beispiel GmbH', postalCode: '67655', city: 'Kaiserslautern', score: 82, reason: 'keine veröffentlichte Mail', sourceUrl: 'https://beispiel-elektro.de/impressum', phone: '0631 111', phoneSource: 'https://beispiel-elektro.de/impressum', @@ -326,3 +343,109 @@ test('nach dem Abbau setzt eine späte Antwort nichts mehr, ein offener Zeitgebe await settle() 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 () => { + 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 }) + assert.deepEqual(dom.focused, []) // nicht schon beim ersten Laden + await ui.click('Weiter') + assert.deepEqual(dom.focused, []) // während der Anfrage ist die Seitenzeile weg + await ui.answer(1, { items: [ITEM], total: 120, pageSize: 50 }) // Seite 2 von 3 + assert.deepEqual(dom.focused, ['Weiter']) + await ui.click('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 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 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']) + } finally { dom.restore(); ui.end() } +}) + +test('Blättern (C5): der Fokus springt nicht, wenn der Nutzer inzwischen Raum oder Suche bedient hat oder die Anfrage scheitert', 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 ui.click('Weiter') + await ui.pick('handwerk') // während die Seite lädt, wechselt der Raum + await ui.answer(1, { items: [ITEM], total: 120, pageSize: 50 }) // späte Antwort der älteren Anfrage + await ui.answer(2, { items: [ITEM], total: 120, pageSize: 50 }) + assert.deepEqual(dom.focused, []) + await ui.click('Weiter') + await ui.type('bäckerei') // die Suche wird getippt, bevor die Seite da ist + await ui.answer(3, { items: [ITEM], total: 120, pageSize: 50 }) + assert.deepEqual(dom.focused, []) + // Raum und Suche selbst verschieben den Fokus nie + ui.fireTimers(); await settle() + await ui.answer(4, { items: [ITEM], total: 120, pageSize: 50 }) + assert.deepEqual(dom.focused, []) + // scheitert die Anfrage, gibt es keine Seitenzeile zum Fokussieren; die Merkung bleibt nicht für später hängen + await ui.click('Weiter') + await ui.fail(5, new Error('Der Hustler-Dienst ist gerade nicht erreichbar.')) + assert.deepEqual(dom.focused, []) + await ui.pick('laden') + await ui.answer(6, { items: [ITEM], total: 120, pageSize: 50 }) + assert.deepEqual(dom.focused, []) + } 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() + let cleared = 0 + const ui = await start(api, { bot: 'p1', onClearBot: () => { cleared += 1 } }) + dom.attach(ui) + try { + await ui.answer(0, { items: [ITEM], total: 1 }) + assert.deepEqual(dom.focused, []) // nicht beim Einhängen (das öffnete am Handy die Tastatur oder die Auswahl) + await ui.click('Alle anzeigen') + assert.equal(cleared, 1) + assert.deepEqual(dom.focused, []) // der Knopf steht noch, bis die Seite bot aus der Adresse nimmt + ui.setProps({ bot: '' }); await settle() + assert.deepEqual(dom.focused, ['select']) + assert.equal(ui.select().props['aria-label'], 'Raum') + // ein weiterer Wechsel in die Einzelansicht und zurück ohne den Knopf (Zurück-Taste des Browsers): kein Fokussprung + ui.setProps({ bot: 'p2' }); await settle() + ui.setProps({ bot: '' }); await settle() + assert.deepEqual(dom.focused, ['select']) + } finally { dom.restore(); ui.end() } +}) + +test('Alle anzeigen (W1): ohne bot (Liste von Anfang an) bleibt der Fokus unberührt', async () => { + const api = fakeApi() + const dom = fakeFocus() + const ui = await start(api) + dom.attach(ui) + try { + await ui.answer(0, { items: [ITEM], total: 1 }) + ui.setProps({ bot: 'p1', onClearBot: () => {} }); await settle() + ui.setProps({ bot: '' }); await settle() + assert.deepEqual(dom.focused, []) + } finally { dom.restore(); ui.end() } +}) + +test('aria-current (C5): nur die Zeile mit der Kennung aus bot, nie eine Zeile ohne Kennung', async () => { + const api = fakeApi() + const ui = await start(api) + try { + // Liste (bot leer): eine Zeile ohne Kennung (botleadRow macht daraus '') gilt nicht als „aktuell“ + await ui.answer(0, { items: [{ ...ITEM, id: undefined }, { ...PRAXIS, id: 5 }, ITEM], total: 3 }) + assert.equal(ui.rows().length, 3) + assert.deepEqual(ui.rows().map((r) => r.props['aria-current']), [undefined, undefined, undefined]) + } finally { ui.end() } + const api2 = fakeApi() + const one = await start(api2, { bot: 'p1', onClearBot: () => {} }) + try { + await one.answer(0, { items: [{ ...ITEM, id: '' }, ITEM, PRAXIS], total: 3 }) + assert.deepEqual(one.rows().map((r) => r.props['aria-current']), [undefined, 'true', undefined]) + } finally { one.end() } +}) diff --git a/tests/unit/botleadsView.test.mjs b/tests/unit/botleadsView.test.mjs index 715889e..de49552 100644 --- a/tests/unit/botleadsView.test.mjs +++ b/tests/unit/botleadsView.test.mjs @@ -96,3 +96,9 @@ test('BotLeadList.jsx: Links nur aus der Zeilen-Ableitung, extern mit rel noopen // kein Polling assert.doesNotMatch(src, /setInterval/) }) + +test('BotLeadList.jsx: Zeilen ohne Kennung teilen sich keinen key (Ausweichwert aus der Position), aria-current nur in der Einzelansicht', () => { + const src = read('src/components/workers/BotLeadList.jsx') + assert.match(src, /key=\{row\.id \|\| /) + assert.match(src, /aria-current=\{single && row\.id === botId \?/) +})