Workers P2: Auswahl aus der Liste holt die Karte ins Bild und setzt den Fokus (Handy und Tablet)

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 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-18 05:41:03 +00:00
1 parent 9ebc562c83
commit 84fb37a98e
5 files changed
+63 -5

No files matched your search

+21 -2
View File
@@ -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 (
<div className="wk-card">
@@ -74,10 +93,10 @@ export default function WorkerCard({ workerId, worker, trace, loading, error, on
const rows = traceRows(trace && trace.events, Date.now())
return (
<section className="wk-card" aria-label="Worker-Karte">
<section className="wk-card" aria-label="Worker-Karte" ref={sectionRef}>
{onClose && <button type="button" className="btn btn-quiet" onClick={onClose} aria-label="Karte schließen">×</button>}
<Portrait hat={hat} grey={status === 'grau'} />
<h2 className="wk-name">{name}</h2>
<h2 className="wk-name" ref={headingRef} tabIndex={-1}>{name}</h2>
{roleText && <p className="wk-role">{roleText}</p>}
<p>
{modelId && <span className={`wk-chip ${CHIP[hat]}`}>{modelId}</span>}
+16 -2
View File
@@ -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 }) {
<div className="wk-main">
<WorkersStage ref={ref} state={state} selectedId={selectedId} onSelect={setSelectedId} />
<div className="wk-side">
<div className="wk-side" ref={sideRef}>
<WorkerCard
workerId={selectedId}
worker={worker}
trace={trace}
loading={traceLoading}
error={traceError}
onClose={() => setSelectedId(null)}
onClose={closeCard}
/>
<WorkerList workers={workers} selectedId={selectedId} onSelect={setSelectedId} />
</div>
+3 -1
View File
@@ -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; }
+17
View File
@@ -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(/<Button\b/g) || []).length, (s.match(/<Button\b[^>]*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, /<h2 className="wk-name" ref=\{headingRef\} tabIndex=\{-1\}>/)
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, /<div className="wk-side" ref=\{sideRef\}>/)
assert.match(board, /onClose=\{closeCard\}/); assert.match(board, /data-wk-worker/); assert.match(board, /button\.focus\(\)/)
assert.doesNotMatch(board, /onClose=\{\(\) => setSelectedId\(null\)\}/)
})
+6
View File
@@ -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
})