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 () => {