frontend: Barrierefreiheit — aria-Labels, Fokusreihenfolge, Live-Region
TASKS Phase 2. Jeder Diagramm-Knoten bekommt in render.js (nodeAria) einen sprechenden, lokalisierten aria-label: Label + Status + Aufwand (inkl. „(angenommen)" beim impliziten M) + Zuständige + Link. Die rein visuellen Badges (Größe, Tags, ↗) sind aria-hidden, damit der Screenreader nicht „M", „anna", „↗" kryptisch doppelt vorliest. Neue a11y*-i18n-Keys in allen 9 Sprachen. Alle Knoten sind fokussierbar (tabindex=0 bzw. der Link) in Dokument-/Lese- reihenfolge; sichtbarer :focus-visible-Rahmen (Petrol). #warn ist eine Live-Region (role=status, aria-live=polite) — neue Warnungen werden angesagt. Verifiziert: aria-label folgt dem Sprachwechsel (DE/EN), Badges aria-hidden, keine visuelle Regression, keine Konsolenfehler. Renderer-Snapshots aktualisiert (29 Tests grün). SPEC §9 (Barrierefreiheit) + CLAUDE ergänzt. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
32a02021ed
commit
15699c321c
@@ -222,6 +222,19 @@ Das Diagramm wird aus der Live-Geometrie in ein eigenständiges SVG (nur Formen
|
|||||||
- Es wird genau die **sichtbare** Struktur exportiert (der „verworfene
|
- Es wird genau die **sichtbare** Struktur exportiert (der „verworfene
|
||||||
einblenden“-Filter wirkt auch hier).
|
einblenden“-Filter wirkt auch hier).
|
||||||
|
|
||||||
|
### Barrierefreiheit
|
||||||
|
Die visuell codierten Knoten-Eigenschaften werden für Screenreader in einem
|
||||||
|
sprechenden **`aria-label`** je Knoten zusammengefasst — Label, Status, Aufwand
|
||||||
|
(inkl. „(angenommen)“ beim impliziten M), Zuständige und ob der Knoten verlinkt
|
||||||
|
ist —, alles in der aktuellen UI-Sprache. Die rein visuellen Beiwerke
|
||||||
|
(Größen-Badge, Tags, ↗-Pfeil) sind `aria-hidden`, damit sie nicht kryptisch
|
||||||
|
doppelt vorgelesen werden. **Alle** Knoten sind fokussierbar (`tabindex="0"`
|
||||||
|
bzw. der Link selbst); die **Fokusreihenfolge entspricht der Dokument-/
|
||||||
|
Lesereihenfolge** (Wurzeln, dann Kinder in Tiefe). Ein sichtbarer
|
||||||
|
`:focus-visible`-Rahmen (Petrol) zeigt den Tastaturfokus. Der Warnungsbereich
|
||||||
|
ist eine **Live-Region** (`role="status"`, `aria-live="polite"`), sodass neue
|
||||||
|
Warnungen (z. B. gemischte Gates) angesagt werden.
|
||||||
|
|
||||||
## 10. Beispiel (kanonisch)
|
## 10. Beispiel (kanonisch)
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|||||||
+6
-1
@@ -31,7 +31,12 @@ Abhaken beim Erledigen; neue Aufgaben unten anfügen.
|
|||||||
Phase 2 (`unknownStatus`). `tests/warnings.test.js`.
|
Phase 2 (`unknownStatus`). `tests/warnings.test.js`.
|
||||||
|
|
||||||
## Phase 2 — Qualität
|
## Phase 2 — Qualität
|
||||||
- [ ] Barrierefreiheit: Fokusreihenfolge, aria-Labels für Status/Größe/Tags.
|
- [x] Barrierefreiheit: Fokusreihenfolge, aria-Labels für Status/Größe/Tags.
|
||||||
|
→ je Knoten ein sprechender `aria-label` (Label+Status+Aufwand+Zuständige+
|
||||||
|
Link, lokalisiert, neue `a11y*`-Keys in allen 9 Sprachen); visuelle Badges
|
||||||
|
`aria-hidden`; Knoten `tabindex="0"` (Fokus = Lesereihenfolge) mit
|
||||||
|
`:focus-visible`-Rahmen; `#warn` als Live-Region (`role=status`,
|
||||||
|
`aria-live=polite`). Snapshots aktualisiert.
|
||||||
- [ ] Druck-Stylesheet (Diagramm ohne Editor-Panel).
|
- [ ] Druck-Stylesheet (Diagramm ohne Editor-Panel).
|
||||||
- [ ] Fehlertolerantes Parsen weiter ausbauen (unbekannte Statuszeichen melden).
|
- [ ] Fehlertolerantes Parsen weiter ausbauen (unbekannte Statuszeichen melden).
|
||||||
|
|
||||||
|
|||||||
@@ -58,6 +58,12 @@ verworfene Elemente. Quelle sind ES-Module unter `src/`; `index.html` ist der
|
|||||||
Umbauten dieses Nesting mitprüfen.
|
Umbauten dieses Nesting mitprüfen.
|
||||||
- „verworfen" ist per Default ausgeblendet; Filterlogik steckt in
|
- „verworfen" ist per Default ausgeblendet; Filterlogik steckt in
|
||||||
`visibleChildren()` und muss bei Renderer-Umbauten erhalten bleiben.
|
`visibleChildren()` und muss bei Renderer-Umbauten erhalten bleiben.
|
||||||
|
- Barrierefreiheit (SPEC §9): `render.js` baut je Knoten einen sprechenden
|
||||||
|
`aria-label` (Label + Status + Aufwand + Zuständige + Link, lokalisiert via
|
||||||
|
`t`); die visuellen Badges (Größe, Tags, ↗) sind `aria-hidden`. Neue
|
||||||
|
Knoten-Eigenschaften dort in `nodeAria()` mitpflegen und dafür a11y-i18n-Keys
|
||||||
|
(`a11y*`) in **allen 9 Sprachen** anlegen. Knoten sind `tabindex="0"`
|
||||||
|
(Fokus = Lesereihenfolge), `#warn` ist eine Live-Region.
|
||||||
- Zustand wird im `localStorage` gehalten (noch kein Backend): `werkbaum-lang`
|
- Zustand wird im `localStorage` gehalten (noch kein Backend): `werkbaum-lang`
|
||||||
(Sprache), `werkbaum-src` (Editortext), `werkbaum-ui` (JSON: Modus,
|
(Sprache), `werkbaum-src` (Editortext), `werkbaum-ui` (JSON: Modus,
|
||||||
verworfene, günstigster Pfad, Split-Zustand inkl. `--col`/`--drow`, Zoom,
|
verworfene, günstigster Pfad, Split-Zustand inkl. `--col`/`--drow`, Zoom,
|
||||||
|
|||||||
+1
-1
@@ -139,7 +139,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="diagram"><ul class="tree" id="out"></ul></div>
|
<div class="diagram"><ul class="tree" id="out"></ul></div>
|
||||||
<div class="warnings" id="warn"></div>
|
<div class="warnings" id="warn" role="status" aria-live="polite"></div>
|
||||||
<div class="zoomctl" data-i18n-aria="zoomAria" aria-label="Zoom (Strg/Cmd + Mausrad)">
|
<div class="zoomctl" data-i18n-aria="zoomAria" aria-label="Zoom (Strg/Cmd + Mausrad)">
|
||||||
<button type="button" id="zoomOut" data-i18n-title="zoomOut" title="verkleinern">−</button>
|
<button type="button" id="zoomOut" data-i18n-title="zoomOut" title="verkleinern">−</button>
|
||||||
<button type="button" id="zoomReset" data-i18n-title="zoomReset" title="zurücksetzen">100 %</button>
|
<button type="button" id="zoomReset" data-i18n-title="zoomReset" title="zurücksetzen">100 %</button>
|
||||||
|
|||||||
@@ -561,6 +561,7 @@ const I18N = {
|
|||||||
mixedWarn:"Zeile {line}: Unter „{label}“ sind - und | gemischt — dargestellt nach dem ersten Kind.",
|
mixedWarn:"Zeile {line}: Unter „{label}“ sind - und | gemischt — dargestellt nach dem ersten Kind.",
|
||||||
st_idee:"Idee", st_geplant:"geplant", st_arbeit:"in Arbeit", st_durchstich:"Durchstich",
|
st_idee:"Idee", st_geplant:"geplant", st_arbeit:"in Arbeit", st_durchstich:"Durchstich",
|
||||||
st_fertig:"fertig", st_prod:"in Produktion", st_verworfen:"verworfen",
|
st_fertig:"fertig", st_prod:"in Produktion", st_verworfen:"verworfen",
|
||||||
|
a11yStatus:"Status: {status}", a11ySize:"Aufwand: {size}", a11ySizeImplicit:"Aufwand: M (angenommen)", a11yTags:"Zuständig: {names}", a11yLink:"verlinkt",
|
||||||
hint_indent:"Einrückung (2 Leerzeichen oder Tab) definiert die Hierarchie.",
|
hint_indent:"Einrückung (2 Leerzeichen oder Tab) definiert die Hierarchie.",
|
||||||
hint_all:"Teilpaket, alle erforderlich", hint_any:"Alternative, eine wählen",
|
hint_all:"Teilpaket, alle erforderlich", hint_any:"Alternative, eine wählen",
|
||||||
hint_root:"Zeile ohne Zeichen = Wurzelknoten. Geschwister sollten dasselbe Zeichen tragen.",
|
hint_root:"Zeile ohne Zeichen = Wurzelknoten. Geschwister sollten dasselbe Zeichen tragen.",
|
||||||
@@ -598,6 +599,7 @@ const I18N = {
|
|||||||
mixedWarn:"Line {line}: under “{label}”, - and | are mixed — rendered by the first child.",
|
mixedWarn:"Line {line}: under “{label}”, - and | are mixed — rendered by the first child.",
|
||||||
st_idee:"idea", st_geplant:"planned", st_arbeit:"in progress", st_durchstich:"walking skeleton",
|
st_idee:"idea", st_geplant:"planned", st_arbeit:"in progress", st_durchstich:"walking skeleton",
|
||||||
st_fertig:"done", st_prod:"in production", st_verworfen:"discarded",
|
st_fertig:"done", st_prod:"in production", st_verworfen:"discarded",
|
||||||
|
a11yStatus:"Status: {status}", a11ySize:"Effort: {size}", a11ySizeImplicit:"Effort: M (assumed)", a11yTags:"Assigned: {names}", a11yLink:"has link",
|
||||||
hint_indent:"Indentation (2 spaces or a tab) defines the hierarchy.",
|
hint_indent:"Indentation (2 spaces or a tab) defines the hierarchy.",
|
||||||
hint_all:"sub-task, all required", hint_any:"alternative, choose one",
|
hint_all:"sub-task, all required", hint_any:"alternative, choose one",
|
||||||
hint_root:"Line without a marker = root node. Siblings should share the same marker.",
|
hint_root:"Line without a marker = root node. Siblings should share the same marker.",
|
||||||
@@ -635,6 +637,7 @@ const I18N = {
|
|||||||
mixedWarn:"Línea {line}: bajo «{label}» se mezclan - y | — se representa según el primer hijo.",
|
mixedWarn:"Línea {line}: bajo «{label}» se mezclan - y | — se representa según el primer hijo.",
|
||||||
st_idee:"idea", st_geplant:"planificado", st_arbeit:"en curso", st_durchstich:"prototipo funcional",
|
st_idee:"idea", st_geplant:"planificado", st_arbeit:"en curso", st_durchstich:"prototipo funcional",
|
||||||
st_fertig:"terminado", st_prod:"en producción", st_verworfen:"descartado",
|
st_fertig:"terminado", st_prod:"en producción", st_verworfen:"descartado",
|
||||||
|
a11yStatus:"Estado: {status}", a11ySize:"Esfuerzo: {size}", a11ySizeImplicit:"Esfuerzo: M (asumido)", a11yTags:"Responsable: {names}", a11yLink:"con enlace",
|
||||||
hint_indent:"La sangría (2 espacios o un tabulador) define la jerarquía.",
|
hint_indent:"La sangría (2 espacios o un tabulador) define la jerarquía.",
|
||||||
hint_all:"subtarea, todas obligatorias", hint_any:"alternativa, elige una",
|
hint_all:"subtarea, todas obligatorias", hint_any:"alternativa, elige una",
|
||||||
hint_root:"Línea sin marcador = nodo raíz. Los hermanos deberían llevar el mismo marcador.",
|
hint_root:"Línea sin marcador = nodo raíz. Los hermanos deberían llevar el mismo marcador.",
|
||||||
@@ -672,6 +675,7 @@ const I18N = {
|
|||||||
mixedWarn:"Ligne {line} : sous « {label} », - et | sont mélangés — rendu selon le premier enfant.",
|
mixedWarn:"Ligne {line} : sous « {label} », - et | sont mélangés — rendu selon le premier enfant.",
|
||||||
st_idee:"idée", st_geplant:"planifié", st_arbeit:"en cours", st_durchstich:"squelette fonctionnel",
|
st_idee:"idée", st_geplant:"planifié", st_arbeit:"en cours", st_durchstich:"squelette fonctionnel",
|
||||||
st_fertig:"terminé", st_prod:"en production", st_verworfen:"abandonné",
|
st_fertig:"terminé", st_prod:"en production", st_verworfen:"abandonné",
|
||||||
|
a11yStatus:"Statut : {status}", a11ySize:"Effort : {size}", a11ySizeImplicit:"Effort : M (supposé)", a11yTags:"Responsable : {names}", a11yLink:"avec lien",
|
||||||
hint_indent:"L'indentation (2 espaces ou une tabulation) définit la hiérarchie.",
|
hint_indent:"L'indentation (2 espaces ou une tabulation) définit la hiérarchie.",
|
||||||
hint_all:"sous-tâche, toutes requises", hint_any:"alternative, en choisir une",
|
hint_all:"sous-tâche, toutes requises", hint_any:"alternative, en choisir une",
|
||||||
hint_root:"Ligne sans marqueur = nœud racine. Les frères devraient porter le même marqueur.",
|
hint_root:"Ligne sans marqueur = nœud racine. Les frères devraient porter le même marqueur.",
|
||||||
@@ -709,6 +713,7 @@ const I18N = {
|
|||||||
mixedWarn:"Wiersz {line}: pod „{label}” mieszają się - i | — renderowane według pierwszego dziecka.",
|
mixedWarn:"Wiersz {line}: pod „{label}” mieszają się - i | — renderowane według pierwszego dziecka.",
|
||||||
st_idee:"pomysł", st_geplant:"zaplanowane", st_arbeit:"w toku", st_durchstich:"działający szkielet",
|
st_idee:"pomysł", st_geplant:"zaplanowane", st_arbeit:"w toku", st_durchstich:"działający szkielet",
|
||||||
st_fertig:"gotowe", st_prod:"w produkcji", st_verworfen:"odrzucone",
|
st_fertig:"gotowe", st_prod:"w produkcji", st_verworfen:"odrzucone",
|
||||||
|
a11yStatus:"Status: {status}", a11ySize:"Nakład: {size}", a11ySizeImplicit:"Nakład: M (założony)", a11yTags:"Przypisano: {names}", a11yLink:"z linkiem",
|
||||||
hint_indent:"Wcięcie (2 spacje lub tabulator) definiuje hierarchię.",
|
hint_indent:"Wcięcie (2 spacje lub tabulator) definiuje hierarchię.",
|
||||||
hint_all:"podzadanie, wszystkie wymagane", hint_any:"alternatywa, wybierz jedną",
|
hint_all:"podzadanie, wszystkie wymagane", hint_any:"alternatywa, wybierz jedną",
|
||||||
hint_root:"Wiersz bez znacznika = węzeł główny. Rodzeństwo powinno mieć ten sam znacznik.",
|
hint_root:"Wiersz bez znacznika = węzeł główny. Rodzeństwo powinno mieć ten sam znacznik.",
|
||||||
@@ -746,6 +751,7 @@ const I18N = {
|
|||||||
mixedWarn:"Строка {line}: под «{label}» смешаны - и | — отображается по первому потомку.",
|
mixedWarn:"Строка {line}: под «{label}» смешаны - и | — отображается по первому потомку.",
|
||||||
st_idee:"идея", st_geplant:"запланировано", st_arbeit:"в работе", st_durchstich:"сквозной прототип",
|
st_idee:"идея", st_geplant:"запланировано", st_arbeit:"в работе", st_durchstich:"сквозной прототип",
|
||||||
st_fertig:"готово", st_prod:"в эксплуатации", st_verworfen:"отклонено",
|
st_fertig:"готово", st_prod:"в эксплуатации", st_verworfen:"отклонено",
|
||||||
|
a11yStatus:"Статус: {status}", a11ySize:"Оценка: {size}", a11ySizeImplicit:"Оценка: M (предполагается)", a11yTags:"Ответственные: {names}", a11yLink:"со ссылкой",
|
||||||
hint_indent:"Отступ (2 пробела или табуляция) задаёт иерархию.",
|
hint_indent:"Отступ (2 пробела или табуляция) задаёт иерархию.",
|
||||||
hint_all:"подзадача, все обязательны", hint_any:"альтернатива, выберите одну",
|
hint_all:"подзадача, все обязательны", hint_any:"альтернатива, выберите одну",
|
||||||
hint_root:"Строка без маркера = корневой узел. У соседних узлов должен быть одинаковый маркер.",
|
hint_root:"Строка без маркера = корневой узел. У соседних узлов должен быть одинаковый маркер.",
|
||||||
@@ -783,6 +789,7 @@ const I18N = {
|
|||||||
mixedWarn:"पंक्ति {line}: „{label}“ के अंतर्गत - और | मिश्रित हैं — पहले चाइल्ड के अनुसार दिखाया गया।",
|
mixedWarn:"पंक्ति {line}: „{label}“ के अंतर्गत - और | मिश्रित हैं — पहले चाइल्ड के अनुसार दिखाया गया।",
|
||||||
st_idee:"विचार", st_geplant:"नियोजित", st_arbeit:"प्रगति पर", st_durchstich:"कार्यशील ढाँचा",
|
st_idee:"विचार", st_geplant:"नियोजित", st_arbeit:"प्रगति पर", st_durchstich:"कार्यशील ढाँचा",
|
||||||
st_fertig:"पूर्ण", st_prod:"उत्पादन में", st_verworfen:"अस्वीकृत",
|
st_fertig:"पूर्ण", st_prod:"उत्पादन में", st_verworfen:"अस्वीकृत",
|
||||||
|
a11yStatus:"स्थिति: {status}", a11ySize:"आकार: {size}", a11ySizeImplicit:"आकार: M (अनुमानित)", a11yTags:"जिम्मेदार: {names}", a11yLink:"लिंक सहित",
|
||||||
hint_indent:"इंडेंट (2 स्पेस या टैब) पदानुक्रम तय करता है।",
|
hint_indent:"इंडेंट (2 स्पेस या टैब) पदानुक्रम तय करता है।",
|
||||||
hint_all:"उप-कार्य, सभी आवश्यक", hint_any:"विकल्प, एक चुनें",
|
hint_all:"उप-कार्य, सभी आवश्यक", hint_any:"विकल्प, एक चुनें",
|
||||||
hint_root:"बिना मार्कर वाली पंक्ति = मूल नोड। सहोदर नोड्स का मार्कर समान होना चाहिए।",
|
hint_root:"बिना मार्कर वाली पंक्ति = मूल नोड। सहोदर नोड्स का मार्कर समान होना चाहिए।",
|
||||||
@@ -820,6 +827,7 @@ const I18N = {
|
|||||||
mixedWarn:"第 {line} 行:在「{label}」下 - 与 | 混用——按第一个子项渲染。",
|
mixedWarn:"第 {line} 行:在「{label}」下 - 与 | 混用——按第一个子项渲染。",
|
||||||
st_idee:"想法", st_geplant:"已计划", st_arbeit:"进行中", st_durchstich:"可运行骨架",
|
st_idee:"想法", st_geplant:"已计划", st_arbeit:"进行中", st_durchstich:"可运行骨架",
|
||||||
st_fertig:"已完成", st_prod:"已上线", st_verworfen:"已放弃",
|
st_fertig:"已完成", st_prod:"已上线", st_verworfen:"已放弃",
|
||||||
|
a11yStatus:"状态:{status}", a11ySize:"工作量:{size}", a11ySizeImplicit:"工作量:M(假定)", a11yTags:"负责人:{names}", a11yLink:"含链接",
|
||||||
hint_indent:"缩进(2 个空格或制表符)定义层级。",
|
hint_indent:"缩进(2 个空格或制表符)定义层级。",
|
||||||
hint_all:"子任务,全部必需", hint_any:"备选项,择其一",
|
hint_all:"子任务,全部必需", hint_any:"备选项,择其一",
|
||||||
hint_root:"无标记的行 = 根节点。同级应使用相同的标记。",
|
hint_root:"无标记的行 = 根节点。同级应使用相同的标记。",
|
||||||
@@ -857,6 +865,7 @@ const I18N = {
|
|||||||
mixedWarn:"{line} 行目:「{label}」の下で - と | が混在 — 最初の子に従って表示。",
|
mixedWarn:"{line} 行目:「{label}」の下で - と | が混在 — 最初の子に従って表示。",
|
||||||
st_idee:"アイデア", st_geplant:"計画済み", st_arbeit:"作業中", st_durchstich:"ウォーキングスケルトン",
|
st_idee:"アイデア", st_geplant:"計画済み", st_arbeit:"作業中", st_durchstich:"ウォーキングスケルトン",
|
||||||
st_fertig:"完了", st_prod:"本番稼働", st_verworfen:"破棄",
|
st_fertig:"完了", st_prod:"本番稼働", st_verworfen:"破棄",
|
||||||
|
a11yStatus:"ステータス: {status}", a11ySize:"規模: {size}", a11ySizeImplicit:"規模: M(想定)", a11yTags:"担当: {names}", a11yLink:"リンクあり",
|
||||||
hint_indent:"インデント(スペース2つまたはタブ)で階層を定義します。",
|
hint_indent:"インデント(スペース2つまたはタブ)で階層を定義します。",
|
||||||
hint_all:"サブタスク、すべて必須", hint_any:"選択肢、1つを選ぶ",
|
hint_all:"サブタスク、すべて必須", hint_any:"選択肢、1つを選ぶ",
|
||||||
hint_root:"マーカーのない行 = ルートノード。兄弟は同じマーカーを使うべきです。",
|
hint_root:"マーカーのない行 = ルートノード。兄弟は同じマーカーを使うべきです。",
|
||||||
|
|||||||
+28
-10
@@ -16,29 +16,47 @@ import { gateOf, needsBreakdown, visibleChildren, cheapCls } from './model.js';
|
|||||||
export function esc(s){
|
export function esc(s){
|
||||||
return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
|
return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
|
||||||
}
|
}
|
||||||
|
/* Escaping für Attributwerte (zusätzlich " -> "). */
|
||||||
|
function attr(s){ return esc(String(s)).replace(/"/g,'"'); }
|
||||||
|
|
||||||
|
/* Barrierefreier Name eines Knotens: Label + Status + Aufwand + Zuständige +
|
||||||
|
Link. Die visuellen Badges (Größe, Tags, ↗) sind aria-hidden — ihre
|
||||||
|
Information steckt hier, sonst würde der Screenreader Kryptisches („M",
|
||||||
|
„anna", „↗") vorlesen. */
|
||||||
|
function nodeAria(n, opts){
|
||||||
|
const { t, cheapPath } = opts;
|
||||||
|
const parts = [n.label];
|
||||||
|
if(n.status) parts.push(t('a11yStatus', {status: t('st_' + n.status.key)}));
|
||||||
|
if(n.size) parts.push(t('a11ySize', {size: n.size}));
|
||||||
|
else if(cheapPath) parts.push(t('a11ySizeImplicit'));
|
||||||
|
if(n.tags && n.tags.length) parts.push(t('a11yTags', {names: n.tags.join(', ')}));
|
||||||
|
if(n.url) parts.push(t('a11yLink'));
|
||||||
|
return parts.join(', ');
|
||||||
|
}
|
||||||
|
|
||||||
function nodeHtml(n, extra, opts){
|
function nodeHtml(n, extra, opts){
|
||||||
const { t, cheapPath } = opts;
|
const { t, cheapPath } = opts;
|
||||||
const need = needsBreakdown(n);
|
const need = needsBreakdown(n);
|
||||||
const cls = ['node', extra || '', n.status ? 'st-' + n.status.key : '']
|
const cls = ['node', extra || '', n.status ? 'st-' + n.status.key : '']
|
||||||
.filter(Boolean).join(' ');
|
.filter(Boolean).join(' ');
|
||||||
const title = n.status ? ` title="${esc(t('st_' + n.status.key)).replace(/"/g,'"')}"` : '';
|
const title = n.status ? ` title="${attr(t('st_' + n.status.key))}"` : '';
|
||||||
const tagsHtml = n.tags && n.tags.length
|
const tagsHtml = n.tags && n.tags.length
|
||||||
? `<span class="tags">${n.tags.map(tag => `<span class="tag">${esc(tag)}</span>`).join('')}</span>`
|
? `<span class="tags" aria-hidden="true">${n.tags.map(tag => `<span class="tag">${esc(tag)}</span>`).join('')}</span>`
|
||||||
: '';
|
: '';
|
||||||
const implicitTip = esc(t('implicitSizeTooltip')).replace(/"/g,'"');
|
const implicitTip = attr(t('implicitSizeTooltip'));
|
||||||
const sizeBadge = n.size
|
const sizeBadge = n.size
|
||||||
? `<span class="size">${n.size}</span>`
|
? `<span class="size" aria-hidden="true">${n.size}</span>`
|
||||||
: (cheapPath ? `<span class="size implicit" title="${implicitTip}">M</span>` : '');
|
: (cheapPath ? `<span class="size implicit" aria-hidden="true" title="${implicitTip}">M</span>` : '');
|
||||||
const inner = esc(n.label) +
|
const inner = esc(n.label) +
|
||||||
(n.url ? '<span class="ext">↗</span>' : '') +
|
(n.url ? '<span class="ext" aria-hidden="true">↗</span>' : '') +
|
||||||
sizeBadge +
|
sizeBadge +
|
||||||
tagsHtml;
|
tagsHtml;
|
||||||
|
const aria = ` aria-label="${attr(nodeAria(n, opts))}"`;
|
||||||
const html = n.url
|
const html = n.url
|
||||||
? `<a class="${cls}" href="${esc(n.url).replace(/"/g,'"')}" target="_blank" rel="noopener"${title}>${inner}</a>`
|
? `<a class="${cls}" href="${attr(n.url)}" target="_blank" rel="noopener"${aria}${title}>${inner}</a>`
|
||||||
: `<div class="${cls}"${title}>${inner}</div>`;
|
: `<div class="${cls}" tabindex="0"${aria}${title}>${inner}</div>`;
|
||||||
const ghostTip = esc(t('ghostTooltip')).replace(/"/g,'"');
|
const ghostTip = attr(t('ghostTooltip'));
|
||||||
const ghost = `<div class="ghost-node" title="${ghostTip}">${esc(t('ghost'))}</div>`;
|
const ghost = `<div class="ghost-node" aria-label="${ghostTip}" title="${ghostTip}">${esc(t('ghost'))}</div>`;
|
||||||
return html + (need ? ghost : '');
|
return html + (need ? ghost : '');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -324,6 +324,9 @@
|
|||||||
}
|
}
|
||||||
a.node{text-decoration:none;cursor:pointer}
|
a.node{text-decoration:none;cursor:pointer}
|
||||||
a.node:hover{box-shadow:0 2px 8px rgba(36,52,71,.22)}
|
a.node:hover{box-shadow:0 2px 8px rgba(36,52,71,.22)}
|
||||||
|
/* Sichtbarer Tastatur-Fokus (Knoten sind fokussierbar: Fokusreihenfolge =
|
||||||
|
Lese-/Dokumentreihenfolge; Screenreader liest den aria-label). */
|
||||||
|
.node:focus-visible{outline:2px solid var(--or);outline-offset:2px;box-shadow:0 2px 8px rgba(36,52,71,.22)}
|
||||||
.ext{font-size:.72em;margin-left:5px;opacity:.55}
|
.ext{font-size:.72em;margin-left:5px;opacity:.55}
|
||||||
.size{
|
.size{
|
||||||
position:absolute;top:-9px;right:-9px;
|
position:absolute;top:-9px;right:-9px;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||||
|
|
||||||
exports[`renderTreeHtml — kanonisches Beispiel > Grundzustand (Pfad aus, verworfene aus): Struktur-Snapshot 1`] = `"<li class="has-and"><a class="node root-node st-arbeit" href="https://wiki.example.de/relaunch" target="_blank" rel="noopener" title="st_arbeit">Website-Relaunch<span class="ext">↗</span><span class="size">XL</span></a><ul class="and"><li class="has-and"><div class="node st-fertig" title="st_fertig">Konzeption<span class="size">M</span></div><ul class="and"><li><div class="node st-fertig" title="st_fertig">Zielgruppenanalyse<span class="size">S</span></div></li><li><div class="node st-fertig" title="st_fertig">Sitemap<span class="size">XS</span></div></li></ul></li><li class="has-and"><div class="node st-arbeit" title="st_arbeit">Umsetzung<span class="size">XL</span></div><ul class="and"><li><a class="node st-durchstich" href="https://git.example.de/frontend" target="_blank" rel="noopener" title="st_durchstich">Frontend<span class="ext">↗</span><span class="size">S</span><span class="tags"><span class="tag">anna</span></span></a></li><li><div class="node st-geplant" title="st_geplant">Backend<span class="size">L</span><span class="tags"><span class="tag">ben</span><span class="tag">carla</span></span></div><div class="ghost-node" title="ghostTooltip">ghost</div></li><li class="has-or"><div class="node st-geplant" title="st_geplant">CMS-Anbindung<span class="size">M</span></div><ul class="or"><li><div class="node st-geplant" title="st_geplant">WordPress</div></li><li><div class="node st-idee" title="st_idee">Headless CMS</div></li></ul></li></ul></li><li class="has-or"><div class="node st-idee" title="st_idee">Hosting<span class="size">M</span></div><ul class="or"><li><div class="node">Cloud</div></li><li><div class="node">On-Premise</div></li></ul></li></ul></li>"`;
|
exports[`renderTreeHtml — kanonisches Beispiel > Grundzustand (Pfad aus, verworfene aus): Struktur-Snapshot 1`] = `"<li class="has-and"><a class="node root-node st-arbeit" href="https://wiki.example.de/relaunch" target="_blank" rel="noopener" aria-label="Website-Relaunch, a11yStatus, a11ySize, a11yLink" title="st_arbeit">Website-Relaunch<span class="ext" aria-hidden="true">↗</span><span class="size" aria-hidden="true">XL</span></a><ul class="and"><li class="has-and"><div class="node st-fertig" tabindex="0" aria-label="Konzeption, a11yStatus, a11ySize" title="st_fertig">Konzeption<span class="size" aria-hidden="true">M</span></div><ul class="and"><li><div class="node st-fertig" tabindex="0" aria-label="Zielgruppenanalyse, a11yStatus, a11ySize" title="st_fertig">Zielgruppenanalyse<span class="size" aria-hidden="true">S</span></div></li><li><div class="node st-fertig" tabindex="0" aria-label="Sitemap, a11yStatus, a11ySize" title="st_fertig">Sitemap<span class="size" aria-hidden="true">XS</span></div></li></ul></li><li class="has-and"><div class="node st-arbeit" tabindex="0" aria-label="Umsetzung, a11yStatus, a11ySize" title="st_arbeit">Umsetzung<span class="size" aria-hidden="true">XL</span></div><ul class="and"><li><a class="node st-durchstich" href="https://git.example.de/frontend" target="_blank" rel="noopener" aria-label="Frontend, a11yStatus, a11ySize, a11yTags, a11yLink" title="st_durchstich">Frontend<span class="ext" aria-hidden="true">↗</span><span class="size" aria-hidden="true">S</span><span class="tags" aria-hidden="true"><span class="tag">anna</span></span></a></li><li><div class="node st-geplant" tabindex="0" aria-label="Backend, a11yStatus, a11ySize, a11yTags" title="st_geplant">Backend<span class="size" aria-hidden="true">L</span><span class="tags" aria-hidden="true"><span class="tag">ben</span><span class="tag">carla</span></span></div><div class="ghost-node" aria-label="ghostTooltip" title="ghostTooltip">ghost</div></li><li class="has-or"><div class="node st-geplant" tabindex="0" aria-label="CMS-Anbindung, a11yStatus, a11ySize" title="st_geplant">CMS-Anbindung<span class="size" aria-hidden="true">M</span></div><ul class="or"><li><div class="node st-geplant" tabindex="0" aria-label="WordPress, a11yStatus" title="st_geplant">WordPress</div></li><li><div class="node st-idee" tabindex="0" aria-label="Headless CMS, a11yStatus" title="st_idee">Headless CMS</div></li></ul></li></ul></li><li class="has-or"><div class="node st-idee" tabindex="0" aria-label="Hosting, a11yStatus, a11ySize" title="st_idee">Hosting<span class="size" aria-hidden="true">M</span></div><ul class="or"><li><div class="node" tabindex="0" aria-label="Cloud">Cloud</div></li><li><div class="node" tabindex="0" aria-label="On-Premise">On-Premise</div></li></ul></li></ul></li>"`;
|
||||||
|
|
||||||
exports[`renderTreeHtml — kanonisches Beispiel > günstigster Pfad an: cheap/cheap-leaf + implizite M-Badges 1`] = `"<li class="has-and"><a class="node root-node cheap st-arbeit" href="https://wiki.example.de/relaunch" target="_blank" rel="noopener" title="st_arbeit">Website-Relaunch<span class="ext">↗</span><span class="size">XL</span></a><ul class="and"><li class="has-and"><div class="node cheap st-fertig" title="st_fertig">Konzeption<span class="size">M</span></div><ul class="and"><li><div class="node cheap cheap-leaf st-fertig" title="st_fertig">Zielgruppenanalyse<span class="size">S</span></div></li><li><div class="node cheap cheap-leaf st-fertig" title="st_fertig">Sitemap<span class="size">XS</span></div></li></ul></li><li class="has-and"><div class="node cheap st-arbeit" title="st_arbeit">Umsetzung<span class="size">XL</span></div><ul class="and"><li><a class="node cheap cheap-leaf st-durchstich" href="https://git.example.de/frontend" target="_blank" rel="noopener" title="st_durchstich">Frontend<span class="ext">↗</span><span class="size">S</span><span class="tags"><span class="tag">anna</span></span></a></li><li><div class="node cheap cheap-leaf st-geplant" title="st_geplant">Backend<span class="size">L</span><span class="tags"><span class="tag">ben</span><span class="tag">carla</span></span></div><div class="ghost-node" title="ghostTooltip">ghost</div></li><li class="has-or"><div class="node cheap st-geplant" title="st_geplant">CMS-Anbindung<span class="size">M</span></div><ul class="or"><li><div class="node cheap cheap-leaf st-geplant" title="st_geplant">WordPress<span class="size implicit" title="implicitSizeTooltip">M</span></div></li><li><div class="node st-idee" title="st_idee">Headless CMS<span class="size implicit" title="implicitSizeTooltip">M</span></div></li></ul></li></ul></li><li class="has-or"><div class="node cheap st-idee" title="st_idee">Hosting<span class="size">M</span></div><ul class="or"><li><div class="node cheap cheap-leaf">Cloud<span class="size implicit" title="implicitSizeTooltip">M</span></div></li><li><div class="node">On-Premise<span class="size implicit" title="implicitSizeTooltip">M</span></div></li></ul></li></ul></li>"`;
|
exports[`renderTreeHtml — kanonisches Beispiel > günstigster Pfad an: cheap/cheap-leaf + implizite M-Badges 1`] = `"<li class="has-and"><a class="node root-node cheap st-arbeit" href="https://wiki.example.de/relaunch" target="_blank" rel="noopener" aria-label="Website-Relaunch, a11yStatus, a11ySize, a11yLink" title="st_arbeit">Website-Relaunch<span class="ext" aria-hidden="true">↗</span><span class="size" aria-hidden="true">XL</span></a><ul class="and"><li class="has-and"><div class="node cheap st-fertig" tabindex="0" aria-label="Konzeption, a11yStatus, a11ySize" title="st_fertig">Konzeption<span class="size" aria-hidden="true">M</span></div><ul class="and"><li><div class="node cheap cheap-leaf st-fertig" tabindex="0" aria-label="Zielgruppenanalyse, a11yStatus, a11ySize" title="st_fertig">Zielgruppenanalyse<span class="size" aria-hidden="true">S</span></div></li><li><div class="node cheap cheap-leaf st-fertig" tabindex="0" aria-label="Sitemap, a11yStatus, a11ySize" title="st_fertig">Sitemap<span class="size" aria-hidden="true">XS</span></div></li></ul></li><li class="has-and"><div class="node cheap st-arbeit" tabindex="0" aria-label="Umsetzung, a11yStatus, a11ySize" title="st_arbeit">Umsetzung<span class="size" aria-hidden="true">XL</span></div><ul class="and"><li><a class="node cheap cheap-leaf st-durchstich" href="https://git.example.de/frontend" target="_blank" rel="noopener" aria-label="Frontend, a11yStatus, a11ySize, a11yTags, a11yLink" title="st_durchstich">Frontend<span class="ext" aria-hidden="true">↗</span><span class="size" aria-hidden="true">S</span><span class="tags" aria-hidden="true"><span class="tag">anna</span></span></a></li><li><div class="node cheap cheap-leaf st-geplant" tabindex="0" aria-label="Backend, a11yStatus, a11ySize, a11yTags" title="st_geplant">Backend<span class="size" aria-hidden="true">L</span><span class="tags" aria-hidden="true"><span class="tag">ben</span><span class="tag">carla</span></span></div><div class="ghost-node" aria-label="ghostTooltip" title="ghostTooltip">ghost</div></li><li class="has-or"><div class="node cheap st-geplant" tabindex="0" aria-label="CMS-Anbindung, a11yStatus, a11ySize" title="st_geplant">CMS-Anbindung<span class="size" aria-hidden="true">M</span></div><ul class="or"><li><div class="node cheap cheap-leaf st-geplant" tabindex="0" aria-label="WordPress, a11yStatus, a11ySizeImplicit" title="st_geplant">WordPress<span class="size implicit" aria-hidden="true" title="implicitSizeTooltip">M</span></div></li><li><div class="node st-idee" tabindex="0" aria-label="Headless CMS, a11yStatus, a11ySizeImplicit" title="st_idee">Headless CMS<span class="size implicit" aria-hidden="true" title="implicitSizeTooltip">M</span></div></li></ul></li></ul></li><li class="has-or"><div class="node cheap st-idee" tabindex="0" aria-label="Hosting, a11yStatus, a11ySize" title="st_idee">Hosting<span class="size" aria-hidden="true">M</span></div><ul class="or"><li><div class="node cheap cheap-leaf" tabindex="0" aria-label="Cloud, a11ySizeImplicit">Cloud<span class="size implicit" aria-hidden="true" title="implicitSizeTooltip">M</span></div></li><li><div class="node" tabindex="0" aria-label="On-Premise, a11ySizeImplicit">On-Premise<span class="size implicit" aria-hidden="true" title="implicitSizeTooltip">M</span></div></li></ul></li></ul></li>"`;
|
||||||
|
|
||||||
exports[`renderTreeHtml — kanonisches Beispiel > verworfene einblenden: Eigenentwicklung erscheint (durchgestrichen) 1`] = `"<li class="has-and"><a class="node root-node st-arbeit" href="https://wiki.example.de/relaunch" target="_blank" rel="noopener" title="st_arbeit">Website-Relaunch<span class="ext">↗</span><span class="size">XL</span></a><ul class="and"><li class="has-and"><div class="node st-fertig" title="st_fertig">Konzeption<span class="size">M</span></div><ul class="and"><li><div class="node st-fertig" title="st_fertig">Zielgruppenanalyse<span class="size">S</span></div></li><li><div class="node st-fertig" title="st_fertig">Sitemap<span class="size">XS</span></div></li></ul></li><li class="has-and"><div class="node st-arbeit" title="st_arbeit">Umsetzung<span class="size">XL</span></div><ul class="and"><li><a class="node st-durchstich" href="https://git.example.de/frontend" target="_blank" rel="noopener" title="st_durchstich">Frontend<span class="ext">↗</span><span class="size">S</span><span class="tags"><span class="tag">anna</span></span></a></li><li><div class="node st-geplant" title="st_geplant">Backend<span class="size">L</span><span class="tags"><span class="tag">ben</span><span class="tag">carla</span></span></div><div class="ghost-node" title="ghostTooltip">ghost</div></li><li class="has-or"><div class="node st-geplant" title="st_geplant">CMS-Anbindung<span class="size">M</span></div><ul class="or"><li><div class="node st-geplant" title="st_geplant">WordPress</div></li><li><div class="node st-idee" title="st_idee">Headless CMS</div></li><li><div class="node st-verworfen" title="st_verworfen">Eigenentwicklung</div></li></ul></li></ul></li><li class="has-or"><div class="node st-idee" title="st_idee">Hosting<span class="size">M</span></div><ul class="or"><li><div class="node">Cloud</div></li><li><div class="node">On-Premise</div></li></ul></li></ul></li>"`;
|
exports[`renderTreeHtml — kanonisches Beispiel > verworfene einblenden: Eigenentwicklung erscheint (durchgestrichen) 1`] = `"<li class="has-and"><a class="node root-node st-arbeit" href="https://wiki.example.de/relaunch" target="_blank" rel="noopener" aria-label="Website-Relaunch, a11yStatus, a11ySize, a11yLink" title="st_arbeit">Website-Relaunch<span class="ext" aria-hidden="true">↗</span><span class="size" aria-hidden="true">XL</span></a><ul class="and"><li class="has-and"><div class="node st-fertig" tabindex="0" aria-label="Konzeption, a11yStatus, a11ySize" title="st_fertig">Konzeption<span class="size" aria-hidden="true">M</span></div><ul class="and"><li><div class="node st-fertig" tabindex="0" aria-label="Zielgruppenanalyse, a11yStatus, a11ySize" title="st_fertig">Zielgruppenanalyse<span class="size" aria-hidden="true">S</span></div></li><li><div class="node st-fertig" tabindex="0" aria-label="Sitemap, a11yStatus, a11ySize" title="st_fertig">Sitemap<span class="size" aria-hidden="true">XS</span></div></li></ul></li><li class="has-and"><div class="node st-arbeit" tabindex="0" aria-label="Umsetzung, a11yStatus, a11ySize" title="st_arbeit">Umsetzung<span class="size" aria-hidden="true">XL</span></div><ul class="and"><li><a class="node st-durchstich" href="https://git.example.de/frontend" target="_blank" rel="noopener" aria-label="Frontend, a11yStatus, a11ySize, a11yTags, a11yLink" title="st_durchstich">Frontend<span class="ext" aria-hidden="true">↗</span><span class="size" aria-hidden="true">S</span><span class="tags" aria-hidden="true"><span class="tag">anna</span></span></a></li><li><div class="node st-geplant" tabindex="0" aria-label="Backend, a11yStatus, a11ySize, a11yTags" title="st_geplant">Backend<span class="size" aria-hidden="true">L</span><span class="tags" aria-hidden="true"><span class="tag">ben</span><span class="tag">carla</span></span></div><div class="ghost-node" aria-label="ghostTooltip" title="ghostTooltip">ghost</div></li><li class="has-or"><div class="node st-geplant" tabindex="0" aria-label="CMS-Anbindung, a11yStatus, a11ySize" title="st_geplant">CMS-Anbindung<span class="size" aria-hidden="true">M</span></div><ul class="or"><li><div class="node st-geplant" tabindex="0" aria-label="WordPress, a11yStatus" title="st_geplant">WordPress</div></li><li><div class="node st-idee" tabindex="0" aria-label="Headless CMS, a11yStatus" title="st_idee">Headless CMS</div></li><li><div class="node st-verworfen" tabindex="0" aria-label="Eigenentwicklung, a11yStatus" title="st_verworfen">Eigenentwicklung</div></li></ul></li></ul></li><li class="has-or"><div class="node st-idee" tabindex="0" aria-label="Hosting, a11yStatus, a11ySize" title="st_idee">Hosting<span class="size" aria-hidden="true">M</span></div><ul class="or"><li><div class="node" tabindex="0" aria-label="Cloud">Cloud</div></li><li><div class="node" tabindex="0" aria-label="On-Premise">On-Premise</div></li></ul></li></ul></li>"`;
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ describe('renderTreeHtml — „Untergliederung fehlt" (Geister-Knoten, SPEC §5
|
|||||||
const {html} = renderTreeHtml(roots, {t, showDiscarded: false, cheapPath: false, cheapSet: new Set()});
|
const {html} = renderTreeHtml(roots, {t, showDiscarded: false, cheapPath: false, cheapSet: new Set()});
|
||||||
expect(count(html, 'ghost-node')).toBe(1);
|
expect(count(html, 'ghost-node')).toBe(1);
|
||||||
expect(html).toContain('title="ghostTooltip"');
|
expect(html).toContain('title="ghostTooltip"');
|
||||||
expect(html).toMatchInlineSnapshot(`"<li><div class="node root-node st-geplant" title="st_geplant">Großes Paket<span class="size">L</span></div><div class="ghost-node" title="ghostTooltip">ghost</div></li>"`);
|
expect(html).toMatchInlineSnapshot(`"<li><div class="node root-node st-geplant" tabindex="0" aria-label="Großes Paket, a11yStatus, a11ySize" title="st_geplant">Großes Paket<span class="size" aria-hidden="true">L</span></div><div class="ghost-node" aria-label="ghostTooltip" title="ghostTooltip">ghost</div></li>"`);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('verworfenes M+ löst die Regel nicht aus', () => {
|
it('verworfenes M+ löst die Regel nicht aus', () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user