diff --git a/src/components/workers/PultList.jsx b/src/components/workers/PultList.jsx
index cd191fc..e6d1a07 100644
--- a/src/components/workers/PultList.jsx
+++ b/src/components/workers/PultList.jsx
@@ -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
. 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 (
-
+
{row.kindLabel}
{row.title}
@@ -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 }) {
))}
- {finished && {finished}
}
+ {finished && {finished}
}
{error && rows.length > 0 && Verbindung gestört: {error} Es wird weiter versucht.
}
{body}
{detailId && setDetailId(null)} />}
diff --git a/src/lib/workersFocus.js b/src/lib/workersFocus.js
new file mode 100644
index 0000000..3c850c7
--- /dev/null
+++ b/src/lib/workersFocus.js
@@ -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 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
+}
diff --git a/tests/unit/workersFocus.test.mjs b/tests/unit/workersFocus.test.mjs
new file mode 100644
index 0000000..ebcb981
--- /dev/null
+++ b/tests/unit/workersFocus.test.mjs
@@ -0,0 +1,59 @@
+import test from 'node:test'
+import assert from 'node:assert/strict'
+import { loadModule } from './load.mjs'
+
+// Fokus nach Pult-Aktionen (Endprüfung P4, W3): ein gesperrter Knopf verliert den Fokus an ; die Zeile gibt ihn danach zurück,
+// aber nur, wenn er wirklich verloren ging. Reine Logik mit einer document-Attrappe, kein DOM nötig.
+const { activeControl, focusLost, restoreFocus } = await loadModule('src/lib/workersFocus.js')
+
+function fakeDoc() {
+ const body = { name: 'body' }
+ const doc = { body, documentElement: { name: 'html' }, activeElement: body }
+ const el = (name, extra = {}) => ({ name, isConnected: true, disabled: false, focusCalls: 0, focus() { this.focusCalls += 1; doc.activeElement = this }, ...extra })
+ return { doc, body, el }
+}
+
+test('focusLost: body, Wurzel, nichts, gesperrtes oder ausgehängtes Element gelten als verloren, ein bedienbares Element nicht', () => {
+ const { doc, body, el } = fakeDoc()
+ assert.equal(focusLost(doc), true) // activeElement = body
+ doc.activeElement = doc.documentElement; assert.equal(focusLost(doc), true)
+ for (const nothing of [null, undefined]) { doc.activeElement = nothing; assert.equal(focusLost(doc), true, String(nothing)) }
+ doc.activeElement = el('a', { disabled: true }); assert.equal(focusLost(doc), true) // der Browser lässt es erst beim nächsten Takt los
+ doc.activeElement = el('b', { isConnected: false }); assert.equal(focusLost(doc), true)
+ doc.activeElement = el('c'); assert.equal(focusLost(doc), false)
+ // ohne document (Node, Server) gibt es nichts zurückzugeben
+ for (const none of [null, undefined]) assert.equal(focusLost(none), false)
+ assert.equal(body.name, 'body')
+})
+
+test('activeControl: das fokussierte Bedienelement, sonst null (body und Wurzel zählen nicht)', () => {
+ const { doc, el } = fakeDoc()
+ assert.equal(activeControl(doc), null)
+ doc.activeElement = doc.documentElement; assert.equal(activeControl(doc), null)
+ doc.activeElement = null; assert.equal(activeControl(doc), null)
+ const button = el('button'); doc.activeElement = button
+ assert.equal(activeControl(doc), button)
+ assert.equal(activeControl(null), null); assert.equal(activeControl(undefined), null)
+})
+
+test('restoreFocus: nimmt den ersten Kandidaten, der noch da und frei ist; greift nur ein, wenn der Fokus verloren ging', () => {
+ const { doc, body, el } = fakeDoc()
+ const gone = el('gone', { isConnected: false })
+ const locked = el('locked', { disabled: true })
+ const first = el('first')
+ const second = el('second')
+ assert.equal(restoreFocus(doc, [null, undefined, gone, locked, first, second]), true)
+ assert.equal(doc.activeElement, first); assert.deepEqual([first.focusCalls, second.focusCalls, gone.focusCalls, locked.focusCalls], [1, 0, 0, 0])
+
+ // der Fokus liegt woanders (die Person hat inzwischen selbst gewählt): nichts wird angefasst
+ const other = el('other'); doc.activeElement = other
+ assert.equal(restoreFocus(doc, [first]), false)
+ assert.equal(doc.activeElement, other); assert.equal(first.focusCalls, 1)
+
+ // kein brauchbarer Kandidat, kein document, keine Liste: false, nichts passiert
+ doc.activeElement = body
+ assert.equal(restoreFocus(doc, [null, gone, locked]), false); assert.equal(doc.activeElement, body)
+ assert.equal(restoreFocus(null, [first]), false); assert.equal(restoreFocus(doc, []), false)
+ // Dinge ohne focus() (Attrappen, Textknoten) werden übersprungen
+ assert.equal(restoreFocus(doc, [{ isConnected: true }, 'text', 7, second]), true); assert.equal(doc.activeElement, second)
+})
diff --git a/tests/unit/workersPultUi.test.mjs b/tests/unit/workersPultUi.test.mjs
index e240802..8e19f48 100644
--- a/tests/unit/workersPultUi.test.mjs
+++ b/tests/unit/workersPultUi.test.mjs
@@ -487,7 +487,7 @@ test('PultList (P4-T32): Quelltext – Aktionen über pultAction, Prüfung mit v
"'reply'", "'approve-preview'", "'paste'", "'start-build'", 'validateReplyText', 'canReply', 'canApprovePreview', 'canStartBuild', 'canPaste', 'previewHref',
'actionErrorText', 'pasteDoneText', 'rel="noopener noreferrer"', 'target="_blank"',
'Als Mensch antworten', 'Jetzt senden', 'Vorschau ansehen', 'Freigeben & senden', 'Wirklich freigeben?', 'Vorschau bauen', 'Wirklich bauen?',
- 'Antwort des Kunden einfügen', 'Einfügen', 'wk-pult-reply', 'wk-pult-paste', 'Abbrechen', 'data-opens', 'Escape',
+ 'Antwort des Kunden einfügen', 'Einfügen', 'wk-pult-reply', 'wk-pult-paste', 'Abbrechen', 'data-opens', 'Escape', 'restoreFocus', 'activeControl', 'tabIndex={-1}',
'Kopiere die Antwort aus der Plattform hierher. Nora sortiert sie ein; bei einer Frage schreibt Clara den nächsten Entwurf.',
]) assert.ok(s.includes(t), t)
assert.doesNotMatch(s, /dangerouslySetInnerHTML/); assert.doesNotMatch(s, /innerHTML/)
@@ -878,6 +878,131 @@ test('PultList (bedient, W4): Esc und „Abbrechen“ schließen auch „Antwort
} finally { ui.end(); f.restore() }
})
+// Endprüfung P4 (W3): ein gesperrter Knopf verliert den Fokus an . Nach der Anfrage gibt die Zeile ihn zurück (an den Knopf, sonst ans Feld, an den
+// Knopf, der das Feld öffnete, oder an die Zeile); verlässt eine Zeile die Liste, geht er an die Meldung oben. Wer den Fokus inzwischen selbst
+// woanders hat, wird nicht gestört. Der Test spielt den Browser nach: activeElement stellt der Test (Klick: Knopf; Sperre: body).
+const PANEL_FIELD = '.wk-pult-reply textarea, .wk-pult-paste textarea'
+
+test('PultList (bedient, W3): nach einem Fehler beim Senden steht der Fokus wieder auf „Jetzt senden“; hat die Person inzwischen selbst gewählt, bleibt er dort', async () => {
+ for (const moved of [false, true]) {
+ const f = fakeFocus()
+ let reject
+ const api = scriptedApi(new Promise((_, no) => { reject = no }))
+ const ui = await startList([unsicher], api)
+ try {
+ await ui.click('Als Mensch antworten')
+ const submit = f.el('submit')
+ f.doc.activeElement = submit // der Klick hat den Knopf fokussiert
+ await ui.click('Jetzt senden')
+ const other = f.el('other')
+ f.doc.activeElement = moved ? other : f.body // der gesperrte Knopf verliert den Fokus (Chromium) – oder die Person ist schon woandershin gegangen
+ reject(failure('Wird schon von einem Kollegen bearbeitet.', 409)); await settle()
+ assert.deepEqual(ui.messages(), [{ kind: 'err', text: 'Wird schon von einem Kollegen bearbeitet.', role: 'alert' }]) // die Zeile bleibt
+ if (moved) assert.deepEqual([f.doc.activeElement, submit.focusCalls], [other, 0])
+ else assert.deepEqual([f.doc.activeElement, submit.focusCalls], [submit, 1])
+ } finally { ui.end(); f.restore() }
+ }
+})
+
+test('PultList (bedient, W3): ist der Knopf nach der Aktion weg („Ich kümmere mich“), geht der Fokus an die Zeile; sie ist mit tabIndex -1 fokussierbar', async () => {
+ const f = fakeFocus()
+ let finish
+ const api = scriptedApi(new Promise((resolve) => { finish = resolve }))
+ const ui = await startList([unsicher], api)
+ try {
+ const li = nodes(ui.tree(), (n) => n.tag === 'li')[0]
+ assert.equal(li.props.tabIndex, -1)
+ const claim = f.el('claim')
+ f.doc.activeElement = claim
+ await ui.click('Ich kümmere mich')
+ f.doc.activeElement = f.body
+ claim.isConnected = false // nach „übernommen“ gibt es den Knopf nicht mehr
+ finish({ item: { $id: 'e1' } }); await settle()
+ const row = f.ref('wk-pult-row')
+ assert.deepEqual([row.focusCalls, f.doc.activeElement], [1, row])
+ assert.equal(claim.focusCalls, 0)
+ } finally { ui.end(); f.restore() }
+})
+
+test('PultList (bedient, W3): nach dem Einfügen (Feld klappt zu) geht der Fokus an „Antwort des Kunden einfügen“; nach einem Fehler bleibt er im Feld', async () => {
+ const f = fakeFocus()
+ const plattform = { ...unsicher, $id: 'k1', kind: 'plattform_antwort', title: 'Suche Webdesigner', draft: 'Claras Entwurf', sourceUrl: 'https://www.kleinanzeigen.de/s-anzeige/x/1-2-3', prospect: { $id: 'p1', name: 'Verein' } }
+ let reject
+ let finish
+ const api = scriptedApi(new Promise((_, no) => { reject = no }), new Promise((resolve) => { finish = resolve }))
+ const ui = await startList([plattform], api)
+ try {
+ const opener = f.el('opener'); f.found.set('[data-opens="paste"]', opener)
+ const field = f.el('textarea'); f.found.set(PANEL_FIELD, field)
+ await ui.click('Antwort des Kunden einfügen')
+ await ui.type('Antwort des Kunden', 'Danke, passt.')
+ // Fehler: das Feld bleibt offen, der Knopf „Einfügen“ ist noch da und bekommt den Fokus zurück
+ const insert = f.el('insert'); f.doc.activeElement = insert
+ await ui.click('Einfügen')
+ f.doc.activeElement = f.body
+ reject(failure('Dieser Kontakt ist gesperrt.', 409)); await settle()
+ assert.deepEqual([f.doc.activeElement, insert.focusCalls], [insert, 1])
+ // Erfolg: das Feld klappt zu (Textfeld und Knopf „Einfügen“ sind weg), der Fokus geht an „Antwort des Kunden einfügen“ (vor der Zeile)
+ f.found.delete(PANEL_FIELD)
+ f.doc.activeElement = insert
+ await ui.click('Einfügen')
+ f.doc.activeElement = f.body
+ insert.isConnected = false
+ finish({ status: 'neu' }); await settle()
+ assert.deepEqual([opener.focusCalls, f.doc.activeElement], [1, opener])
+ assert.equal(field.focusCalls, 0); assert.equal(f.ref('wk-pult-row').focusCalls, 0)
+ // und bleibt das Feld offen, aber der Knopf ist weg, nimmt das Textfeld den Fokus (vor dem Auslöser)
+ } finally { ui.end(); f.restore() }
+})
+
+test('PultList (bedient, W3): verlässt die Zeile die Liste, geht der Fokus an die Meldung oben (auch bei gleichem Text); sie ist mit tabIndex -1 fokussierbar', async () => {
+ const f = fakeFocus()
+ const zweite = { ...unsicher, $id: 'e2' } // gleicher Titel: dieselbe Meldung zweimal
+ let finish
+ const api = scriptedApi(undefined, undefined, new Promise((resolve) => { finish = resolve }))
+ const ui = await startList([unsicher, zweite], api)
+ try {
+ // der Knopf, den die Anfrage gerade sperrt (disabled): der Browser lässt ihn erst beim nächsten Takt los, dann liegt der Fokus auf
+ const done0 = f.el('done0', { disabled: true }); f.doc.activeElement = done0
+ await ui.click('Erledigt', 0)
+ const status = f.ref('msg')
+ assert.deepEqual(ui.messages(), [{ kind: 'ok', text: '„Unklare Antwort“ erledigt.', role: 'status' }])
+ assert.equal(nodes(ui.tree(), (n) => n.tag === 'div' && hasClass(n, 'msg'))[0].props.tabIndex, -1)
+ assert.deepEqual([status.focusCalls, f.doc.activeElement], [1, status])
+ assert.equal(done0.focusCalls, 0)
+ // zweite Zeile, gleiche Meldung: wieder auf die Meldung
+ f.doc.activeElement = f.el('done1', { disabled: true })
+ await ui.click('Erledigt', 1)
+ assert.equal(status.focusCalls, 2); assert.equal(f.doc.activeElement, status)
+ // hat die Person den Fokus während der Anfrage selbst woanders hingesetzt, bleibt er dort
+ f.doc.activeElement = f.el('done2', { disabled: true })
+ await ui.click('Erledigt', 0)
+ const other = f.el('other'); f.doc.activeElement = other
+ finish({}); await settle()
+ assert.deepEqual([status.focusCalls, f.doc.activeElement], [2, other])
+ } finally { ui.end(); f.restore() }
+})
+
+test('PultList (bedient, W3): ohne document (Server, Node) und ohne Fokus vor dem Klick passiert nichts', async () => {
+ const api = scriptedApi()
+ const ui = await startList([unsicher], api) // kein document, kein fakeRef: refs bleiben null
+ try {
+ await ui.click('Ich kümmere mich')
+ await ui.click('Erledigt')
+ assert.equal(api.calls.length, 2)
+ } finally { ui.end() }
+ // das document ist da, aber der Klick hatte keinen Fokus (Safari fokussiert Knöpfe nicht): nichts zurückzugeben, nichts wird bewegt – auch nicht zur Meldung oben
+ const f = fakeFocus()
+ const ui2 = await startList([unsicher], scriptedApi())
+ try {
+ await ui2.click('Ich kümmere mich')
+ assert.deepEqual([f.ref('wk-pult-row').focusCalls, f.doc.activeElement], [0, f.body])
+ await ui2.click('Erledigt')
+ assert.ok(ui2.text().includes('erledigt.'))
+ assert.deepEqual([f.ref('msg').focusCalls, f.doc.activeElement], [0, f.body])
+ } finally { ui2.end(); f.restore() }
+})
+
// Endprüfung P4 (W2): „Vorschau bauen“ startet einen Opus-Lauf und lässt sich in der Oberfläche nicht zurücknehmen: wie Freigeben und Verwerfen
// fragt der Knopf zuerst nach (4 s). Ein Fehlklick (Handy: der Knopf liegt in voller Breite zwischen anderen) startet keinen Bau mehr.
test('PultList (bedient, W2): „Vorschau bauen“ – erster Klick fragt nach (4 s, Hinweis), zweiter startet den Bau; Fehler und Erfolg wie bisher', async () => {