workers: Bot-Leads behält den Tastaturfokus beim Blättern und nach „Alle anzeigen“, aria-current nur in der Einzelansicht

Endprüfung P6 (C5, W1): Beim Blättern hängt die Seitenzeile beim Laden aus, und „Alle anzeigen“
wird durch den Raumfilter ersetzt; der Fokus fiel jeweils auf <body>.
- Blättern: gemerkt wird der gedrückte Knopf; nach dem Laden bekommt er den Fokus zurück, ist er
  gesperrt (erste oder letzte Seite), der andere. Raum oder Suche bedient der Nutzer inzwischen,
  geschieht nichts; scheitert die Anfrage, gibt es keine Seitenzeile und nichts geschieht.
- „Alle anzeigen“: der Raumfilter bekommt den Fokus, sobald die Seite bot aus der Adresse
  genommen hat. Nicht beim Einhängen (am Handy öffnete das die Auswahl) und nicht bei einem
  anderen Weg zur Liste (Zurück-Taste).
- aria-current nur mit bot (single) und passender Kennung; Zeilen ohne Kennung teilen sich keinen key mehr.
- Tests: botleadList.test.mjs (Fokus-Attrappe über __fakeRef, Mutationsprobe für die Reset-Zeilen),
  botleadsView.test.mjs (Quelltext-Probe); der Prüfstand prüft zusätzlich den Fokus nach „Alle anzeigen“.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-20 15:19:52 +00:00
1 parent 78c8e3fbc2
commit ed5dfa9c0e
4 files changed
+174 -6

No files matched your search

+44 -6
View File
@@ -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 <body>, 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 }) {
<tr>{COLUMNS.map((name) => <th key={name} scope="col">{name}</th>)}</tr>
</thead>
<tbody>
{rows.map((row) => (
<tr key={row.id} className={row.gekuerzt ? 'wk-bl-cut' : undefined} aria-current={row.id === botId ? 'true' : undefined}>
{rows.map((row, index) => (
<tr key={row.id || `ohne-id-${index}`} className={row.gekuerzt ? 'wk-bl-cut' : undefined} aria-current={single && row.id === botId ? 'true' : undefined}>
<td>
<div className="cell-title">{row.title}{row.gekuerzt && <span className="wk-bl-tag">gekürzt</span>}</div>
{row.place && <div className="cell-sub">{row.place}</div>}
@@ -116,11 +154,11 @@ export default function BotLeadList({ api, bot, onClearBot }) {
{single ? (
<div className="toolbar wk-bl-single">
<span className="wk-hint">Einzelner Eintrag aus dem Ereignisstrom</span>
<button type="button" className="btn" onClick={onClearBot}>Alle anzeigen</button>
<button type="button" className="btn" onClick={onClear}>Alle anzeigen</button>
</div>
) : (
<div className="toolbar">
<select className="select" aria-label="Raum" value={view.room} onChange={onRoom}>
<select className="select" aria-label="Raum" value={view.room} onChange={onRoom} ref={selectRef}>
{ROOM_FILTERS.map((f) => <option key={f.value} value={f.value}>{f.label}</option>)}
</select>
<input className="input search" type="search" aria-label="Betriebe suchen" placeholder="Name, Ort oder Grund" maxLength={SEARCH_MAX} value={draft} onChange={onSearch} />
@@ -132,8 +170,8 @@ export default function BotLeadList({ api, bot, onClearBot }) {
<span>{info.label}</span>
{info.pages > 1 && (
<span className="wk-bl-pager">
<button type="button" className="btn" disabled={!info.hasPrev} onClick={() => setView((v) => ({ ...v, page: v.page - 1 }))}>Zurück</button>
<button type="button" className="btn" disabled={!info.hasNext} onClick={() => setView((v) => ({ ...v, page: v.page + 1 }))}>Weiter</button>
<button type="button" className="btn" ref={prevRef} disabled={!info.hasPrev} onClick={() => onPage('prev')}>Zurück</button>
<button type="button" className="btn" ref={nextRef} disabled={!info.hasNext} onClick={() => onPage('next')}>Weiter</button>
</span>
)}
</div>