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 +})