From 84fb37a98e61014de9393ec234a4b7b19614a647 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 05:41:03 +0000 Subject: [PATCH] Workers P2: Auswahl aus der Liste holt die Karte ins Bild und setzt den Fokus (Handy und Tablet) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Endprüfung P2. Unter 1100 px steht die Karte über der Liste mit 19 Einträgen, aus der man wählt (die Liste ist dort die Alternative zur Leinwand). Ein Klick in der Liste änderte nichts Sichtbares: die Karte öffnete sich oberhalb des Fensters, Screenreader hörten nichts, und „Karte schließen“ ließ den Fokus ins Leere fallen. Bei neuer Auswahl nimmt jetzt der Name in der Karte den Fokus (tabIndex -1), und liegt der Kartenanfang außerhalb des Fensters, wird die Karte an den oberen Rand geholt (scroll-margin-top: unter der festen Leiste der Handy-Ansicht 64 px). „Karte schließen“ gibt den Fokus an den Listeneintrag des Workers zurück. Co-Authored-By: Claude Sonnet 5.5 --- src/components/workers/WorkerCard.jsx | 23 +++++++++++++++++++++-- src/components/workers/WorkersBoard.jsx | 18 ++++++++++++++++-- src/styles/workers.css | 4 +++- tests/unit/workersComponents.test.mjs | 17 +++++++++++++++++ tests/unit/workersCss.test.mjs | 6 ++++++ 5 files changed, 63 insertions(+), 5 deletions(-) diff --git a/src/components/workers/WorkerCard.jsx b/src/components/workers/WorkerCard.jsx index 8424243..ffb289a 100644 --- a/src/components/workers/WorkerCard.jsx +++ b/src/components/workers/WorkerCard.jsx @@ -17,6 +17,8 @@ const CHIP = { const PORTRAIT_W = 72 const PORTRAIT_H = 96 +// Beginnt die Karte tiefer als so viele Pixel über der Unterkante des Fensters, wird sie nach oben geholt +const MIN_VISIBLE = 120 const isRecord = (v) => v !== null && typeof v === 'object' && !Array.isArray(v) @@ -52,6 +54,23 @@ function TraceText({ link, children }) { // Karte eines Workers: Porträt, Rolle, Modell, Status, Tageswerte und die letzten Aktionen. // worker = Zustandseintrag (status, task, counters, connected), trace = Antwort von api.trace (oder null). export default function WorkerCard({ workerId, worker, trace, loading, error, onClose }) { + const sectionRef = useRef(null) + const headingRef = useRef(null) + + // Neue Auswahl: Der Name nimmt den Fokus (Screenreader sagen die Karte an), und liegt der Kartenanfang außerhalb des Fensters, + // wird die Karte an den oberen Rand geholt. Auf Handy und Tablet steht die Karte über der langen Liste, aus der man wählt. + useEffect(() => { + if (!workerId) return + const heading = headingRef.current + if (heading && typeof heading.focus === 'function') heading.focus({ preventScroll: true }) + const section = sectionRef.current + if (!section || typeof section.scrollIntoView !== 'function') return + const top = section.getBoundingClientRect().top + const viewport = window.innerHeight || document.documentElement.clientHeight + const margin = parseFloat(window.getComputedStyle(section).scrollMarginTop) || 0 + if (top < margin || top > viewport - MIN_VISIBLE) section.scrollIntoView({ block: 'start' }) + }, [workerId]) + if (!workerId) { return (
@@ -74,10 +93,10 @@ export default function WorkerCard({ workerId, worker, trace, loading, error, on const rows = traceRows(trace && trace.events, Date.now()) return ( -
+
{onClose && } -

{name}

+

{name}

{roleText &&

{roleText}

}

{modelId && {modelId}} diff --git a/src/components/workers/WorkersBoard.jsx b/src/components/workers/WorkersBoard.jsx index e9f5afd..dfb7bab 100644 --- a/src/components/workers/WorkersBoard.jsx +++ b/src/components/workers/WorkersBoard.jsx @@ -28,6 +28,7 @@ export default function WorkersBoard({ api, isAdmin, stageRef }) { const ownRef = useRef(null) const ref = stageRef ?? ownRef const aliveRef = useRef(true) + const sideRef = useRef(null) const [workers, setWorkers] = useState({}) const [selectedId, setSelectedId] = useState(null) const [settingsOpen, setSettingsOpen] = useState(false) @@ -68,6 +69,19 @@ export default function WorkersBoard({ api, isAdmin, stageRef }) { const paused = Boolean(state.paused) const worker = selectedId && Object.hasOwn(workers, selectedId) ? workers[selectedId] : undefined + // Karte schließen: der Fokus geht zurück auf den Listeneintrag des Workers (sonst fiele er mit dem Knopf „ד ins Leere) + const closeCard = () => { + const id = selectedId + setSelectedId(null) + if (!id || !sideRef.current) return + for (const button of sideRef.current.querySelectorAll('[data-wk-worker]')) { + if (button.getAttribute('data-wk-worker') === id) { + button.focus() + return + } + } + } + // Pause für alle Mitarbeiter: der Dienst bekommt den Zielwert, danach wird der Zustand sofort neu geholt const togglePause = async () => { if (pausing) return @@ -103,14 +117,14 @@ export default function WorkersBoard({ api, isAdmin, stageRef }) {

-
+
setSelectedId(null)} + onClose={closeCard} />
diff --git a/src/styles/workers.css b/src/styles/workers.css index b46dd68..04f01e3 100644 --- a/src/styles/workers.css +++ b/src/styles/workers.css @@ -73,7 +73,7 @@ /* ---------- Worker-Karte ---------- */ -.wk-card { position: relative; padding-bottom: 20px; } +.wk-card { position: relative; padding-bottom: 20px; scroll-margin-top: 12px; } .wk-card > .btn-quiet { position: absolute; top: 0; right: 0; } .wk-card h3 { margin: 20px 0 8px; font-size: 13px; line-height: 20px; font-weight: 600; color: var(--text-2); } .wk-portrait { @@ -180,6 +180,8 @@ } @media (max-width: 860px) { + /* unter der festen Leiste der Handy-Ansicht (52 px) */ + .wk-card { scroll-margin-top: 64px; } .wk-kpis.stats { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px 24px; } .wk-zoom { gap: 6px; } .wk-zoom .btn { min-width: 36px; min-height: 36px; } diff --git a/tests/unit/workersComponents.test.mjs b/tests/unit/workersComponents.test.mjs index 775fde5..4a5a8a2 100644 --- a/tests/unit/workersComponents.test.mjs +++ b/tests/unit/workersComponents.test.mjs @@ -65,3 +65,20 @@ test('WorkersBoard: Zustände und Rechte (Review Focus 1)', () => { // PF-08: jeder Knopf ein type="button" (WOMS-Button hat keinen Standard-type) assert.equal((s.match(/]*type="button"/g) || []).length) }) +test('Karte und Board: Auswahl setzt den Fokus auf den Namen und holt die Karte ins Bild, Schließen gibt den Fokus zurück (Endprüfung P2, Handy/Tablet)', () => { + const card = read('WorkerCard.jsx') + // Namen fokussierbar machen (tabIndex -1), Fokus ohne eigenes Springen, danach gezielt an den oberen Rand holen, wenn der Kartenanfang nicht im Fenster liegt + assert.match(card, /

/) + assert.match(card, /ref=\{sectionRef\}/) + assert.match(card, /heading\.focus\(\{ preventScroll: true \}\)/) + assert.match(card, /section\.scrollIntoView\(\{ block: 'start' \}\)/) + assert.match(card, /scrollMarginTop/) + assert.match(card, /\}, \[workerId\]\)/) + // Hooks stehen vor der frühen Rückgabe ohne Auswahl (Regeln der Hooks) + assert.ok(card.indexOf('useEffect(() => {\n if (!workerId) return') > 0) + assert.ok(card.indexOf('useEffect(() => {\n if (!workerId) return') < card.indexOf(' if (!workerId) {\n return (')) + const board = read('WorkersBoard.jsx') + assert.match(board, /const sideRef = useRef\(null\)/); assert.match(board, /
/) + assert.match(board, /onClose=\{closeCard\}/); assert.match(board, /data-wk-worker/); assert.match(board, /button\.focus\(\)/) + assert.doesNotMatch(board, /onClose=\{\(\) => setSelectedId\(null\)\}/) +}) diff --git a/tests/unit/workersCss.test.mjs b/tests/unit/workersCss.test.mjs index 8e708ca..dc802d6 100644 --- a/tests/unit/workersCss.test.mjs +++ b/tests/unit/workersCss.test.mjs @@ -18,3 +18,9 @@ test('workers.css: Zoomknöpfe unten rechts (oben läge die Reihe über dem Schi assert.match(rule('.wk-zoom'), /bottom:\s*8px/); assert.match(rule('.wk-zoom'), /right:\s*8px/); assert.doesNotMatch(rule('.wk-zoom'), /\btop:/) assert.match(rule('.wk-toolbar ~ .msg'), /margin-bottom:\s*16px/) }) +test('workers.css: die Karte bleibt beim Anspringen unter der festen Leiste der Handy-Ansicht (scroll-margin-top)', () => { + const css = readFileSync(new URL('../../src/styles/workers.css', import.meta.url), 'utf8').replace(/\/\*[\s\S]*?\*\//g, '') + assert.match(css, /\.wk-card\s*\{[^}]*scroll-margin-top:\s*12px/) + const phone = css.slice(css.indexOf('@media (max-width: 860px)')) + assert.match(phone, /\.wk-card\s*\{\s*scroll-margin-top:\s*64px/) // 52 px Leiste + Luft +})