diff --git a/public/neu/ansichten.css b/public/neu/ansichten.css index 07362e0..3a19372 100644 --- a/public/neu/ansichten.css +++ b/public/neu/ansichten.css @@ -120,7 +120,7 @@ .kn-az-kopf .kn-pille { flex: none; margin-top: 2px; } .kn-az-info { margin: 8px 0 0; font-size: 14px; line-height: 20px; } .kn-az-info a { color: var(--kn-orange-fg); text-decoration: none; } -.kn-az-info-link { font-weight: 600; text-decoration: underline; text-decoration-color: currentColor; text-underline-offset: 3px; } +.kn-az-info-link { font-weight: 600; text-decoration: underline; text-decoration-color: currentColor; text-underline-offset: 3px; white-space: nowrap; } /* der Linktext bleibt zusammen */ .kn-az-balken { display: flex; gap: 3px; margin: 12px 0 0; padding: 0; list-style: none; } .kn-az-balken li { position: relative; flex: 1; height: 4px; } .kn-az-balken li::before { content: ""; position: absolute; inset: 0; border-radius: 2px; background: var(--kn-line); } /* die Fläche liegt im Pseudo-Element: der Lesetext darunter bleibt auf Weiß */ diff --git a/public/neu/auftrags-zeitleiste.js b/public/neu/auftrags-zeitleiste.js index a961cf0..529adb3 100644 --- a/public/neu/auftrags-zeitleiste.js +++ b/public/neu/auftrags-zeitleiste.js @@ -122,12 +122,15 @@ const KEINE_EINTRAEGE = 'Noch keine Arbeiten dokumentiert.' /** Knopf, der den nächsten Wrapper [data-einklappbar] auf- und zuklappt (is-open); die Zeilen dahinter tragen [data-extra]. Die CSS-Regeln dazu gelten nur für direkte Kinder (ansichten.css, .kn-az-…): die allgemeine Regel .kn-einklappbar würde bei verschachtelten Wrappern auch offene Zeilen verbergen. */ -function umschalter({ attribut, klasse, mehr, weniger }) { - const knopf = h('button', { type: 'button', class: klasse, [attribut]: true, 'aria-expanded': 'false' }, mehr) +function umschalter({ attribut, klasse, mehr, weniger, name, steuert }) { + // name: Zusatz für den Namen des Knopfes („mehr anzeigen zu „Titel““, der sichtbare Text steht vorn im Namen); steuert: Id der Liste, die er auf- und zuklappt + const nameFuer = (text) => (name ? `${text} zu „${name}“` : null) + const knopf = h('button', { type: 'button', class: klasse, [attribut]: true, 'aria-expanded': 'false', 'aria-label': nameFuer(mehr), 'aria-controls': steuert || null }, mehr) knopf.addEventListener('click', () => { const offen = knopf.closest('[data-einklappbar]').classList.toggle('is-open') knopf.textContent = offen ? weniger : mehr knopf.setAttribute('aria-expanded', String(offen)) + if (name) knopf.setAttribute('aria-label', nameFuer(offen ? weniger : mehr)) }) return knopf } @@ -143,28 +146,34 @@ function baueBalken(status) { h('span', { class: 'kn-nur-lesen' }, `${stufe.name}, ${zustandText(stufe)}`)))) } -function baueZeitleiste(zeilen) { +let listenNummer = 0 // Ids der Listen für aria-controls: eindeutig je Seite, auch wenn die Ansicht mehrmals gezeichnet wird + +function baueZeitleiste(zeilen, titel) { const { sichtbar, rest } = kuerze(zeilen, MAX_ZEILEN) + listenNummer += 1 + const listenId = `kn-az-zeilen-${listenNummer}` const zeile = (z, extra) => h('li', { 'data-extra': extra }, z.datum ? h('time', { class: 'kn-az-datum', datetime: z.iso || null }, z.datum) : h('span', { class: 'kn-az-datum', 'aria-hidden': 'true' }), h('span', { class: 'kn-az-text' }, z.text)) return h('div', { class: 'kn-az-zeitleiste', 'data-einklappbar': true }, // Trägt ein Datum das Jahr (TT.MM.JJ), bekommt die Datumsspalte aller Zeilen die Breite dafür, damit die Texte bündig bleiben - h('ol', { class: zeilen.some((z) => z.datum.length > 6) ? 'kn-az-zeilen kn-az-mit-jahr' : 'kn-az-zeilen' }, sichtbar.map((z) => zeile(z, false)), rest.map((z) => zeile(z, true))), - rest.length ? umschalter({ attribut: 'data-mehr', klasse: 'kn-textknopf kn-az-mehr', mehr: 'mehr anzeigen', weniger: 'weniger anzeigen' }) : null) + h('ol', { id: listenId, class: zeilen.some((z) => z.datum.length > 6) ? 'kn-az-zeilen kn-az-mit-jahr' : 'kn-az-zeilen' }, sichtbar.map((z) => zeile(z, false)), rest.map((z) => zeile(z, true))), + // Mehrere Knöpfe „mehr anzeigen“ auf einer Seite: der Name nennt den Auftrag, aria-controls die Liste + rest.length ? umschalter({ attribut: 'data-mehr', klasse: 'kn-textknopf kn-az-mehr', mehr: 'mehr anzeigen', weniger: 'weniger anzeigen', name: titel, steuert: listenId }) : null) } function baueAuftragsBlock(auftrag, jetzt, extra) { const status = auftrag.status && typeof auftrag.status === 'object' ? auftrag.status : {} const zeilen = zeitleistenZeilen(auftrag, jetzt) + const titel = auftrag.topic || 'Dein Auftrag' return h('li', { class: 'kn-az', 'data-auftrag': auftrag.number || null, 'data-extra': extra }, h('div', { class: 'kn-az-kopf' }, - h('h3', { class: 'kn-az-titel' }, auftrag.topic || 'Dein Auftrag'), + h('h3', { class: 'kn-az-titel' }, titel), status.label ? h('span', { class: K.pille[statusTon(status.tone)] }, status.label) : null), // Die ganze Zeile führt in den Chat (große Tippfläche am Handy); unterstrichen ist nur „Im Chat antworten“ braucheInfo(auftrag) ? h('p', { class: 'kn-az-info' }, h('a', zuAnsicht('chat'), 'Wir brauchen eine Info von dir · ', h('span', { class: 'kn-az-info-link' }, 'Im Chat antworten'))) : null, baueBalken(status), - zeilen.length ? baueZeitleiste(zeilen) : h('p', { class: 'kn-az-leer' }, KEINE_EINTRAEGE)) + zeilen.length ? baueZeitleiste(zeilen, titel) : h('p', { class: 'kn-az-leer' }, KEINE_EINTRAEGE)) } /** „Was wir zuletzt erledigt haben“: ein Block je Auftrag; fünf offen, der Rest hinter „Ältere Aufträge anzeigen“. Ohne Aufträge ein ruhiger Satz. */ diff --git a/test/knAuftragsZeitleisteDom.test.js b/test/knAuftragsZeitleisteDom.test.js index 2afff6f..140968b 100644 --- a/test/knAuftragsZeitleisteDom.test.js +++ b/test/knAuftragsZeitleisteDom.test.js @@ -99,6 +99,37 @@ test('Aktivität: Zeitleiste mit Datum und Kurztext, drei sichtbar, „mehr anze assert.equal(fx.alle(el, '[data-mehr]').length, 1) })) +// Barrierefreiheit: Mehrere Knöpfe „mehr anzeigen“ auf einer Seite sind für Screenreader nicht zu unterscheiden. Der Name nennt darum den Auftrag (und enthält den sichtbaren +// Text, WCAG 2.5.3), aria-controls zeigt auf die Liste, die der Knopf aufklappt. +test('Zeitleiste: „mehr anzeigen“ trägt aria-label mit dem Auftragstitel und aria-controls auf die eigene Liste, auch nach dem Umschalten', () => mitDom((fx) => { + const vier = (nr, titel) => auftrag(nr, 'arbeit', '2026-10-06T10:00:00Z', ['a', 'b', 'c', 'd'].map((t, j) => eintrag(`2026-10-1${j}`, t)), titel) + const el = baueAktivitaet([vier(1, 'Speisekarte als PDF'), vier(2, 'Neue Fotos für die Startseite')], JETZT) + const [erster, zweiter] = fx.alle(el, '[data-auftrag]') + const knopf = (block) => fx.finde(block, '[data-mehr]') + const liste = (block) => fx.finde(block, '.kn-az-zeilen') + assert.equal(knopf(erster).textContent, 'mehr anzeigen') + assert.equal(knopf(erster).getAttribute('aria-label'), 'mehr anzeigen zu „Speisekarte als PDF“') + assert.equal(knopf(zweiter).getAttribute('aria-label'), 'mehr anzeigen zu „Neue Fotos für die Startseite“') // nur Text, nie HTML + // aria-controls: die Id der Liste desselben Auftrags, eindeutig auf der Seite + const ids = [liste(erster).getAttribute('id'), liste(zweiter).getAttribute('id')] + assert.ok(ids.every((id) => /^[A-Za-z][\w-]*$/.test(id)) && ids[0] !== ids[1], ids.join()) + assert.deepEqual([knopf(erster), knopf(zweiter)].map((k) => k.getAttribute('aria-controls')), ids) + assert.ok(knopf(erster).getAttribute('aria-label').startsWith(knopf(erster).textContent)) // der sichtbare Text steht im Namen + // Umschalten: Text, Zustand und Name ziehen mit, die Verknüpfung bleibt + fx.klick(knopf(erster)) + assert.equal(knopf(erster).textContent, 'weniger anzeigen') + assert.equal(knopf(erster).getAttribute('aria-label'), 'weniger anzeigen zu „Speisekarte als PDF“') + assert.equal(knopf(erster).getAttribute('aria-controls'), ids[0]) + assert.equal(knopf(zweiter).getAttribute('aria-label'), 'mehr anzeigen zu „Neue Fotos für die Startseite“') // der andere Knopf bleibt + fx.klick(knopf(erster)) + assert.equal(knopf(erster).getAttribute('aria-label'), 'mehr anzeigen zu „Speisekarte als PDF“') + // ohne Titel gilt der Ersatztitel der Überschrift; „Ältere Aufträge anzeigen“ bleibt wie es war + const ohneTitel = baueAktivitaet([{ ...vier(3, ''), topic: '' }], JETZT) + assert.equal(fx.finde(ohneTitel, '[data-mehr]').getAttribute('aria-label'), 'mehr anzeigen zu „Dein Auftrag“') + const sechs = baueAktivitaet(Array.from({ length: 6 }, (_, i) => vier(10 + i, `T${i}`)), JETZT) + assert.equal(fx.finde(sechs, '[data-aeltere]').getAttribute('aria-label'), null) +})) + test('Aktivität: Auftrag ohne Einträge zeigt einen ruhigen Satz statt einer leeren Zeitleiste', () => mitDom((fx) => { const el = baueAktivitaet([auftrag(1, 'arbeit', '2026-10-05T00:00:00Z', [])], JETZT) assert.equal(fx.finde(el, '.kn-az-zeitleiste'), null); assert.match(fx.text(el), /Noch keine Arbeiten dokumentiert\./) @@ -175,6 +206,13 @@ test('Rückfrage-Link: am Handy ein Block (kein flex) mit Innenabstand für 44 p assert.doesNotMatch(css, /\.kn-az-info a \{[^}]*display: *(inline-)?flex/) }) +// Browserlauf 17.10. (Prüfbefund): „Im Chat antworten“ brach am schmalen Handy mitten im Text um. Der Linktext bleibt zusammen; passt er nicht mehr in die Zeile, wandert er ganz in die nächste. +test('Rückfrage-Link: der Linktext „Im Chat antworten“ bricht nicht um (white-space: nowrap auf .kn-az-info-link)', () => { + const css = readFileSync(new URL('../public/neu/ansichten.css', import.meta.url), 'utf8') + assert.match(css, /\n\.kn-az-info-link \{[^}]*\bwhite-space: nowrap;[^}]*\}/) + assert.doesNotMatch(css, /\.kn-az-info a \{[^}]*white-space: nowrap/) // der Satz davor darf umbrechen +}) + test('Zeitleiste: trägt ein Eintrag das Jahr (Vorjahr), richten sich alle Datumsspalten des Auftrags danach', () => mitDom((fx) => { const mitJahr = baueAktivitaet([auftrag(1, 'arbeit', '2026-10-05T00:00:00Z', [eintrag('2026-10-06', 'neu'), eintrag('2025-12-24', 'alt')])], JETZT) assert.deepEqual(fx.alle(mitJahr, 'time').map((t) => t.textContent), ['06.10.', '24.12.25']); assert.ok(fx.finde(mitJahr, '.kn-az-zeilen').classList.contains('kn-az-mit-jahr'))