Workers P4 Endprüfung W3: Fokus bleibt nach Pult-Aktionen erhalten statt auf <body> zu fallen

Jede Aktion sperrt die Knöpfe der Zeile; Chromium nimmt einem gesperrten Knopf den Fokus und gibt ihn nicht
zurück, Tastatur- und Screenreader-Nutzer standen danach am Seitenanfang. begin() merkt sich das fokussierte
Bedienelement und das offene Feld; ist die Anfrage vorbei, geht der Fokus zurück an den Knopf, sonst ans
Textfeld, an den Knopf, der das Feld öffnete, sonst an die Zeile (tabIndex -1). Verlässt die Zeile die
Liste, nimmt die Meldung oben (tabIndex -1) den Fokus, auch bei gleichem Text zweimal nacheinander. Es wird
nur eingegriffen, wenn der Fokus wirklich verloren ging und vor der Aktion ein Bedienelement ihn hatte
(Safari fokussiert Knöpfe nicht). Neues Modul workersFocus.js (rein, document wird übergeben).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-18 23:14:11 +00:00
1 parent ff3d042c0e
commit 7c974ab6db
4 files changed
+262 -5

No files matched your search

+47 -4
View File
@@ -5,6 +5,7 @@ import EmptyState from '../ui/EmptyState'
import { copyTextChecked } from '../CopyableCredential'
import { useEmployees } from '../../hooks/useEmployees'
import { usePultFeed } from '../../hooks/usePultFeed'
import { activeControl, restoreFocus } from '../../lib/workersFocus'
import {
ACTION_FAILED, PULT_FILTERS, REPLY_TEXT_MAX, actionErrorText, charCount, copyAndOpen, mailtoHref, pasteDoneText, rowModel, validateReplyText,
} from '../../lib/workersPult'
@@ -15,6 +16,11 @@ const CONFIRM_MS = 4000 // so lange gilt die Rückfrage „Wirklich verwerfen?
const BUILD_HINT = 'Startet einen Opus-Lauf und baut die Vorschau, als hätte der Interessent Ja gesagt.'
const NO_FIELDS = Object.freeze({}) // Eintrag ohne bearbeitete Felder
// Das document des Browsers; unter Node (Tests, Server) gibt es keins, dann bleibt der Fokus unangetastet
const browserDocument = () => (typeof document === 'undefined' ? null : document)
// Selektor des Textfelds eines aufgeklappten Feldes (Antwort oder Einfügen)
const PANEL_FIELD = '.wk-pult-reply textarea, .wk-pult-paste textarea'
// Leerzustand je Filter
const EMPTY = {
offen: { title: 'Nichts wartet auf dich', hint: 'Neue Antworten erscheinen hier, sobald ein Entwurf bereit ist.' },
@@ -85,6 +91,9 @@ function PultRow({ api, item, row, text, fields, onText, onField, refresh, onFin
const busyRef = useRef(false) // sperrt schon im selben Takt, in dem busy noch nicht greift (Doppelklick)
const aliveRef = useRef(true)
const actionsRef = useRef(null) // die Aktionsgruppe: darin sucht close() den Knopf, der ein Feld geöffnet hat
const rowRef = useRef(null) // die Zeile selbst (tabIndex -1): letzter Ort für den Fokus, wenn der Knopf nach der Aktion weg ist
const returnTo = useRef(null) // { control, panel }: wer beim Start der Aktion den Fokus hatte und welches Feld damals offen war
const wasBusy = useRef(false)
useEffect(() => {
aliveRef.current = true
@@ -98,9 +107,33 @@ function PultRow({ api, item, row, text, fields, onText, onField, refresh, onFin
return () => window.clearTimeout(timer)
}, [confirming])
// Fokus nach einer Aktion (Endprüfung W3): die gesperrten Knöpfe verlieren den Fokus an <body>. Ist die Anfrage vorbei (busy wieder false, die Knöpfe sind
// frei), geht er zurück: an den Knopf, der ihn hatte; ist der weg, an das offene Feld, an den Knopf, der das Feld öffnete, sonst an die Zeile.
// Hat die Person den Fokus inzwischen selbst woanders, bleibt er dort. Eine Zeile, die die Liste verlassen hat, gibt ihn nicht mehr zurück: das tut die
// Meldung oben (PultList).
useEffect(() => {
if (busy) {
wasBusy.current = true
return
}
if (!wasBusy.current) return
wasBusy.current = false
const memory = returnTo.current
returnTo.current = null
if (!memory?.control) return // beim Start hatte nichts den Fokus: es ging nichts verloren
const row = rowRef.current
restoreFocus(browserDocument(), [
memory.control,
row?.querySelector(PANEL_FIELD),
memory.panel ? actionsRef.current?.querySelector(`[data-opens="${memory.panel}"]`) : null,
row,
])
}, [busy])
const begin = () => {
if (busyRef.current) return false
busyRef.current = true
returnTo.current = { control: activeControl(browserDocument()), panel: open } // ohne Fokus beim Start (Safari fokussiert Knöpfe nicht) gibt es nichts zurückzugeben
setBusy(true)
setConfirming(null)
setNote(null)
@@ -120,7 +153,7 @@ function PultRow({ api, item, row, text, fields, onText, onField, refresh, onFin
try {
const result = await call()
const message = typeof done === 'function' ? done(result) : done
if (finished) onFinished(row.id, `„${row.title}“ ${message}`)
if (finished) onFinished(row.id, `„${row.title}“ ${message}`, returnTo.current?.control != null)
else if (aliveRef.current) setNote({ kind: 'ok', text: message })
ok = true
} catch (err) {
@@ -228,7 +261,7 @@ function PultRow({ api, item, row, text, fields, onText, onField, refresh, onFin
: ''
return (
<li className="wk-pult-row">
<li className="wk-pult-row" ref={rowRef} tabIndex={-1}>
<div className="wk-pult-head">
<Badge tone="blue">{row.kindLabel}</Badge>
<strong className="wk-pult-title">{row.title}</strong>
@@ -378,20 +411,30 @@ export default function PultList({ api, me }) {
const [drafts, setDrafts] = useState({}) // bearbeitete Texte je Eintrag; was fehlt, zeigt den Entwurf des Dienstes
const [fields, setFields] = useState({}) // Texte der Felder „Als Mensch antworten“ (reply) und „Antwort einfügen“ (paste) je Eintrag
const [finished, setFinished] = useState('') // Meldung über den zuletzt abgeschlossenen Eintrag (seine Zeile ist dann schon weg)
const [focusAsk, setFocusAsk] = useState(0) // zählt die Abschlüsse, bei denen die Person den Fokus auf einem Knopf hatte: auch eine gleiche Meldung zweimal nacheinander holt ihn
const finishedRef = useRef(null)
const [detailId, setDetailId] = useState(null) // Interessent, dessen Panel offen ist (null = keins)
const filter = PULT_FILTERS.find((f) => f.id === filterId) ?? PULT_FILTERS[0]
const { items, loading, error, refresh } = usePultFeed({ api, statuses: filter.statuses })
const { employees } = useEmployees()
const nowMs = Date.now()
// Verlässt eine Zeile die Liste, hängt ihr Knopf mit dem Fokus ab. Die Meldung oben nimmt ihn (tabIndex -1), wenn er sonst nirgends liegt:
// Tastatur- und Screenreader-Nutzer bleiben in der Nähe der Liste und hören die Bestätigung (Endprüfung W3).
useEffect(() => {
if (focusAsk === 0) return
restoreFocus(browserDocument(), [finishedRef.current])
}, [focusAsk])
const chooseFilter = (id) => {
setFilterId(id)
setFinished('')
}
const setText = useCallback((id, value) => setDrafts((d) => ({ ...d, [id]: value })), [])
const setField = useCallback((id, which, value) => setFields((f) => ({ ...f, [id]: { ...f[id], [which]: value } })), [])
const onFinished = useCallback((id, message) => {
const onFinished = useCallback((id, message, hadFocus) => {
setFinished(message)
if (hadFocus) setFocusAsk((n) => n + 1)
setDrafts((d) => {
const { [id]: _gone, ...rest } = d
return rest
@@ -457,7 +500,7 @@ export default function PultList({ api, me }) {
))}
</div>
</div>
{finished && <div className="msg ok" role="status">{finished}</div>}
{finished && <div className="msg ok" role="status" tabIndex={-1} ref={finishedRef}>{finished}</div>}
{error && rows.length > 0 && <div className="msg warn" role="status">Verbindung gestört: {error} Es wird weiter versucht.</div>}
{body}
{detailId && <ProspectPanel api={api} prospectId={detailId} onClose={() => setDetailId(null)} />}
+30
View File
@@ -0,0 +1,30 @@
// Fokus nach Pult-Aktionen (Endprüfung P4, W3). Während einer Anfrage sind die Knöpfe der Zeile gesperrt; ein gesperrter Knopf verliert in
// Chromium den Fokus an <body> und bekommt ihn nach der Anfrage nicht zurück. Tastatur- und Screenreader-Nutzer stünden nach jeder Aktion
// wieder am Seitenanfang. Die Zeile merkt sich beim Start, wer den Fokus hatte, und gibt ihn danach zurück, aber nur, wenn er wirklich
// verloren ging (wer inzwischen selbst woandershin gegangen ist, wird nicht gestört).
// Rein: das document wird übergeben (unter Node eine Attrappe, im Browser das echte; ohne document passiert nichts).
// Liegt der Fokus nirgends? body, die Wurzel, nichts, ein gesperrtes oder ausgehängtes Element (der Browser lässt es erst beim nächsten Takt los)
export function focusLost(doc) {
if (!doc) return false
const active = doc.activeElement
return !active || active === doc.body || active === doc.documentElement || active.disabled === true || active.isConnected === false
}
// Das Bedienelement mit dem Fokus, sonst null (body und Wurzel sind kein Bedienelement)
export function activeControl(doc) {
if (!doc) return null
const active = doc.activeElement
return !active || active === doc.body || active === doc.documentElement ? null : active
}
const usable = (el) => Boolean(el) && typeof el === 'object' && typeof el.focus === 'function' && el.isConnected !== false && el.disabled !== true
// Gibt den Fokus an den ersten brauchbaren Kandidaten (eingehängt, nicht gesperrt), falls er verloren ging. Ergebnis: ob fokussiert wurde.
export function restoreFocus(doc, candidates) {
if (!focusLost(doc)) return false
const target = candidates.find(usable)
if (!target) return false
target.focus()
return true
}