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

+22 -10
View File
@@ -1,16 +1,19 @@
import { useEffect, useRef, useState } from 'react'
import EmptyState from '../ui/EmptyState'
import { ROOM_FILTERS, botleadRow, pageInfo } from '../../lib/botleadsView'
import { restoreFocus } from '../../lib/workersFocus'
const SEARCH_DELAY_MS = 300 // so lange wartet die Suche nach dem letzten Tastendruck, bevor sie den Dienst fragt
const SEARCH_MAX = 100 // der Dienst kürzt die Suche auf 100 Zeichen
const UNKNOWN_ERROR = 'Unbekannter Fehler'
const COLUMNS = ['Betrieb', 'Raum', 'Punkte', 'Grund', 'Telefon', 'Quelle', 'Gefunden']
const START_VIEW = Object.freeze({ room: '', q: '', page: 1 }) // Raumfilter, geltende Suche, Seite: ein Wert, damit eine Änderung genau eine Anfrage auslöst
const browserDocument = () => (typeof document === 'undefined' ? null : document) // unter Node (Tests, Server) gibt es keins, dann bleibt der Fokus unangetastet
// Reiter „Bot-Leads“: Betriebe, die nicht angeschrieben werden (z. B. keine veröffentlichte Mail), mit Grund. Reine Liste, nichts wird gesendet oder angerufen.
// Die Liste holt sich ihre Daten bei jeder Änderung von Raum, Suche, Seite oder bot selbst; es gibt kein Polling. Nur die jüngste Anfrage zählt: eine späte
// Antwort (auch ein später Fehler) einer älteren Anfrage oder nach dem Abbau wird verworfen.
// Antwort (auch ein später Fehler) einer älteren Anfrage oder nach dem Abbau wird verworfen. Scheitert eine Anfrage, steht der Satz des Dienstes mit „Erneut versuchen“,
// das genau diese Anfrage (Raum, Suche, Seite oder Eintrag) noch einmal stellt; Raum und Suche gehen weiterhin.
// bot (aus ?bot= der Adresse, Link aus dem Ereignisstrom): zeigt genau diesen Eintrag, hervorgehoben, mit „Alle anzeigen“ (onClearBot nimmt bot aus der Adresse).
// Alle Texte der Zeilen sind Textknoten; Links entstehen nur aus row.sourceHref (botleadRow prüft die Adresse mit safeHref: nur http und https, keine Zugangsdaten).
export default function BotLeadList({ api, bot, onClearBot }) {
@@ -19,6 +22,7 @@ export default function BotLeadList({ api, bot, onClearBot }) {
const [view, setView] = useState(START_VIEW)
const [draft, setDraft] = useState('') // Inhalt des Suchfelds; in view.q steht erst die Suche, die nach der Verzögerung gilt
const [result, setResult] = useState({ key: null, items: [], total: 0, pageSize: undefined, error: null })
const [attempt, setAttempt] = useState(0) // zählt „Erneut versuchen“: gehört zum Schlüssel, damit dieselbe Anfrage noch einmal läuft und „Wird geladen“ zeigt
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
@@ -27,9 +31,9 @@ export default function BotLeadList({ api, bot, onClearBot }) {
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 pagedRef = useRef('') // 'prev' oder 'next': welcher Blätterknopf zuletzt gedrückt wurde; nach dem Laden bekommt die Seitenzeile den Fokus zurück (auch nach „Erneut versuchen“)
const key = JSON.stringify(single ? [botId] : [view.room, view.q, view.page])
const key = JSON.stringify(single ? [botId, attempt] : [view.room, view.q, view.page, attempt])
useEffect(() => {
seqRef.current += 1
@@ -60,6 +64,7 @@ export default function BotLeadList({ api, bot, onClearBot }) {
useEffect(() => () => window.clearTimeout(timerRef.current), [])
const onRetry = () => setAttempt((n) => n + 1)
const onClear = () => {
clearedRef.current = true
onClearBot()
@@ -92,16 +97,16 @@ export default function BotLeadList({ api, bot, onClearBot }) {
}, [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.
// Nur wenn er wirklich verloren ging (body, nirgends, der alte Knopf ist ausgehängt): wer inzwischen selbst woandershin gegangen ist (Suchfeld, Raumfilter), wird
// nicht gestört. Ohne preventScroll spränge die Seite ans Ende der neuen Seite, denn die Seitenzeile steht unter der Tabelle (restoreFocus in workersFocus.js).
// Scheiterte die Anfrage, gibt es keine Seitenzeile; die Merkung bleibt dann für „Erneut versuchen“ stehen (Raum, Suche und neues Blättern setzen sie neu oder zurück).
useEffect(() => {
if (loading) return
if (loading || result.error) 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])
restoreFocus(browserDocument(), dir === 'next' ? [nextRef.current, prevRef.current] : [prevRef.current, nextRef.current])
}, [loading, result.error])
const rows = result.items.map((item) => botleadRow(item))
const info = pageInfo({ total: result.total, page: view.page, pageSize: result.pageSize })
@@ -111,7 +116,14 @@ export default function BotLeadList({ api, bot, onClearBot }) {
if (loading) {
body = <div className="loading" role="status">Wird geladen …</div>
} else if (result.error) {
body = <div className="msg err" role="alert">{result.error}</div>
body = (
<>
<div className="msg err" role="alert">{result.error}</div>
<div className="toolbar wk-bl-retry">
<button type="button" className="btn" onClick={onRetry}>Erneut versuchen</button>
</div>
</>
)
} else if (rows.length === 0) {
if (single) body = <EmptyState title="Dieser Eintrag ist nicht (mehr) in der Liste." hint="Er wurde gekürzt oder ist kein Bot-Lead." />
else if (filtered) body = <EmptyState title="Keine Treffer." hint="Für diesen Raum und diese Suche gibt es keine Bot-Leads." />
+4 -1
View File
@@ -264,6 +264,8 @@
color: var(--grey-fg);
}
.wk-bl-pager { display: flex; gap: 8px; }
/* „Erneut versuchen“ unter der Fehlermeldung (die Meldung selbst hat keinen Abstand nach unten) */
.wk-bl-retry { margin-top: 8px; }
/* ---------- Schmale Bildschirme ---------- */
@@ -293,10 +295,11 @@
Der Dialog hängt nicht unter einer wk-Klasse, die Regel beginnt deshalb mit der Klasse des Knopfs. Mit (0,3,0) (Klasse, Klasse, Attribut) gewinnt sie
gegen (0,2,0), gleich in welcher Reihenfolge die Dateien geladen werden. */
.wk-prospect-close.btn[type="button"] { width: 100%; min-height: 40px; justify-content: center; }
/* Bot-Leads: Blätterknöpfe und „Alle anzeigen“ der Einzelansicht mit 40 px Tippfläche wie die Knöpfe des Pults; min-height schlägt height: 32px von .btn, und mit
/* Bot-Leads: Blätterknöpfe, „Alle anzeigen“ der Einzelansicht und „Erneut versuchen“ mit 40 px Tippfläche wie die Knöpfe des Pults; min-height schlägt height: 32px von .btn, und mit
zwei Klassen (0,2,0) gegen (0,1,0) braucht es kein !important. Keine globale .btn-Regel: Dialoge und andere Seiten behalten ihre Höhe. Die Zeile darf umbrechen */
.wk-bl-pager .btn { min-height: 40px; }
.wk-bl-single .btn { min-height: 40px; }
.wk-bl-retry .btn { min-height: 40px; }
.wk-bl .table-foot { flex-wrap: wrap; gap: 8px 16px; }
}