v2.5.1: Admin theme refactor, Navigation plugin, user roles, guide restructure

- Reorganize admin into admin/theme/default/ (views + assets)
- Rename GuideNav to Navigation plugin (essential, protected)
- Plugin assets support (SCSS/CSS) loaded after theme CSS
- User roles: Admin, Content Manager, BI Manager, Site Admin
- Role-based access control (RBAC) for admin routes and sidebar
- Guide restructure: sub-topics in separate folders with sidebar nav
- Dynamic breadcrumb for homepage and subdirectories
- Fix theme path traversal (../../ -> ../) in admin.php
- Fix CodeMirror mode load order (xml -> css -> js -> htmlmixed -> php)
- Fix editor-toolbar.js null checks for plugin edit pages
- Layout select from theme.json with live frontmatter update
- Footer sticky at bottom of viewport (min-height: 100vh)
- Breadcrumb color fix (var(--nav-font) -> var(--header-bg))
- Remove language switcher from guide pages
- Update README.md and README.en.md
- Bump version to 2.5.1
This commit is contained in:
2026-08-10 15:36:29 +02:00
parent 7fc3847bbb
commit 3a55ea4db6
210 changed files with 11033 additions and 9140 deletions
@@ -0,0 +1 @@
!function(e){"object"==typeof exports&&"object"==typeof module?e(require("../../lib/codemirror")):"function"==typeof define&&define.amd?define(["../../lib/codemirror"],e):e(CodeMirror)}(function(S){var n={pairs:"()[]{}''\"\"",closeBefore:")]}'\":;>",triples:"",explode:"[]{}"},k=S.Pos;function y(e,t){return"pairs"==t&&"string"==typeof e?e:("object"==typeof e&&null!=e[t]?e:n)[t]}S.defineOption("autoCloseBrackets",!1,function(e,t,n){n&&n!=S.Init&&(e.removeKeyMap(i),e.state.closeBrackets=null),t&&(r(y(t,"pairs")),e.state.closeBrackets=t,e.addKeyMap(i))});var i={Backspace:function(e){var t=O(e);if(!t||e.getOption("disableInput"))return S.Pass;for(var n=y(t,"pairs"),r=e.listSelections(),i=0;i<r.length;i++){if(!r[i].empty())return S.Pass;var a=s(e,r[i].head);if(!a||n.indexOf(a)%2!=0)return S.Pass}for(i=r.length-1;0<=i;i--){var o=r[i].head;e.replaceRange("",k(o.line,o.ch-1),k(o.line,o.ch+1),"+delete")}},Enter:function(r){var e=O(r),t=e&&y(e,"explode");if(!t||r.getOption("disableInput"))return S.Pass;for(var i=r.listSelections(),n=0;n<i.length;n++){if(!i[n].empty())return S.Pass;var a=s(r,i[n].head);if(!a||t.indexOf(a)%2!=0)return S.Pass}r.operation(function(){var e=r.lineSeparator()||"\n";r.replaceSelection(e+e,null),m(r,-1),i=r.listSelections();for(var t=0;t<i.length;t++){var n=i[t].head.line;r.indentLine(n,null,!0),r.indentLine(n+1,null,!0)}})}};function r(e){for(var t=0;t<e.length;t++){var n=e.charAt(t),r="'"+n+"'";i[r]||(i[r]=function(P){return function(e){var i=e,t=P,e=O(i);if(!e||i.getOption("disableInput"))return S.Pass;var n=y(e,"pairs"),r=n.indexOf(t);if(-1==r)return S.Pass;for(var a,o=y(e,"closeBefore"),s=y(e,"triples"),l=n.charAt(r+1)==t,c=i.listSelections(),h=r%2==0,f=0;f<c.length;f++){var u,d=c[f],p=d.head,g=i.getRange(p,k(p.line,p.ch+1));if(h&&!d.empty())u="surround";else if(!l&&h||g!=t)if(l&&1<p.ch&&0<=s.indexOf(t)&&i.getRange(k(p.line,p.ch-2),p)==t+t){if(2<p.ch&&/\bstring/.test(i.getTokenTypeAt(k(p.line,p.ch-2))))return S.Pass;u="addFour"}else if(l){d=0==p.ch?" ":i.getRange(k(p.line,p.ch-1),p);if(S.isWordChar(g)||d==t||S.isWordChar(d))return S.Pass;u="both"}else{if(!h||!(0===g.length||/\s/.test(g)||-1<o.indexOf(g)))return S.Pass;u="both"}else u=l&&function(e,t){var n=e.getTokenAt(k(t.line,t.ch+1));return/\bstring/.test(n.type)&&n.start==t.ch&&(0==t.ch||!/\bstring/.test(e.getTokenTypeAt(t)))}(i,p)?"both":0<=s.indexOf(t)&&i.getRange(p,k(p.line,p.ch+3))==t+t+t?"skipThree":"skip";if(a){if(a!=u)return S.Pass}else a=u}var v=r%2?n.charAt(r-1):t,b=r%2?t:n.charAt(r+1);i.operation(function(){if("skip"==a)m(i,1);else if("skipThree"==a)m(i,3);else if("surround"==a){for(var e=i.getSelections(),t=0;t<e.length;t++)e[t]=v+e[t]+b;i.replaceSelections(e,"around");for(e=i.listSelections().slice(),t=0;t<e.length;t++)e[t]=(n=e[t],r=void 0,r=0<S.cmpPos(n.anchor,n.head),{anchor:new k(n.anchor.line,n.anchor.ch+(r?-1:1)),head:new k(n.head.line,n.head.ch+(r?1:-1))});i.setSelections(e)}else"both"==a?(i.replaceSelection(v+b,null),i.triggerElectric(v+b),m(i,-1)):"addFour"==a&&(i.replaceSelection(v+v+v+v,"before"),m(i,1));var n,r})}}(n))}}function O(e){var t=e.state.closeBrackets;return t&&!t.override&&e.getModeAt(e.getCursor()).closeBrackets||t}function m(e,t){for(var n=[],r=e.listSelections(),i=0,a=0;a<r.length;a++){var o=r[a],o=(o.head==e.getCursor()&&(i=a),o.head.ch||0<t?{line:o.head.line,ch:o.head.ch+t}:{line:o.head.line-1});n.push({anchor:o,head:o})}e.setSelections(n,i)}function s(e,t){e=e.getRange(k(t.line,t.ch-1),k(t.line,t.ch+1));return 2==e.length?e:null}r(n.pairs+"`")});
@@ -0,0 +1 @@
!function(e){"object"==typeof exports&&"object"==typeof module?e(require("../../lib/codemirror")):"function"==typeof define&&define.amd?define(["../../lib/codemirror"],e):e(CodeMirror)}(function(i){"use strict";var a="CodeMirror-activeline",s="CodeMirror-activeline-background",c="CodeMirror-activeline-gutter";function l(e){for(var t=0;t<e.state.activeLines.length;t++)e.removeLineClass(e.state.activeLines[t],"wrap",a),e.removeLineClass(e.state.activeLines[t],"background",s),e.removeLineClass(e.state.activeLines[t],"gutter",c)}function o(t,e){for(var n=[],i=0;i<e.length;i++){var o=e[i],r=t.getOption("styleActiveLine");("object"==typeof r&&r.nonEmpty?o.anchor.line==o.head.line:o.empty())&&(r=t.getLineHandleVisualStart(o.head.line),n[n.length-1]!=r&&n.push(r))}!function(e,t){if(e.length==t.length){for(var n=0;n<e.length;n++)if(e[n]!=t[n])return;return 1}}(t.state.activeLines,n)&&t.operation(function(){l(t);for(var e=0;e<n.length;e++)t.addLineClass(n[e],"wrap",a),t.addLineClass(n[e],"background",s),t.addLineClass(n[e],"gutter",c);t.state.activeLines=n})}function r(e,t){o(e,t.ranges)}i.defineOption("styleActiveLine",!1,function(e,t,n){n=n!=i.Init&&n;t!=n&&(n&&(e.off("beforeSelectionChange",r),l(e),delete e.state.activeLines),t&&(e.state.activeLines=[],o(e,e.listSelections()),e.on("beforeSelectionChange",r)))})});
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
!function(t){"object"==typeof exports&&"object"==typeof module?t(require("../../lib/codemirror"),require("../xml/xml"),require("../javascript/javascript"),require("../css/css")):"function"==typeof define&&define.amd?define(["../../lib/codemirror","../xml/xml","../javascript/javascript","../css/css"],t):t(CodeMirror)}(function(m){"use strict";var l={script:[["lang",/(javascript|babel)/i,"javascript"],["type",/^(?:text|application)\/(?:x-)?(?:java|ecma)script$|^module$|^$/i,"javascript"],["type",/./,"text/plain"],[null,null,"javascript"]],style:[["lang",/^css$/i,"css"],["type",/^(text\/)?(x-)?(stylesheet|css)$/i,"css"],["type",/./,"text/plain"],[null,null,"css"]]};var a={};function d(t,e){e=t.match(a[t=e]||(a[t]=new RegExp("\\s+"+t+"\\s*=\\s*('|\")?([^'\"]+)('|\")?\\s*")));return e?/^\s*(.*?)\s*$/.exec(e[2])[1]:""}function g(t,e){return new RegExp((e?"^":"")+"</\\s*"+t+"\\s*>","i")}function o(t,e){for(var a in t)for(var n=e[a]||(e[a]=[]),l=t[a],o=l.length-1;0<=o;o--)n.unshift(l[o])}m.defineMode("htmlmixed",function(i,t){var c=m.getMode(i,{name:"xml",htmlMode:!0,multilineTagIndentFactor:t.multilineTagIndentFactor,multilineTagIndentPastTag:t.multilineTagIndentPastTag,allowMissingTagName:t.allowMissingTagName}),s={},e=t&&t.tags,a=t&&t.scriptTypes;if(o(l,s),e&&o(e,s),a)for(var n=a.length-1;0<=n;n--)s.script.unshift(["type",a[n].matches,a[n].mode]);function u(t,e){var a,o,r,n=c.token(t,e.htmlState),l=/\btag\b/.test(n);return l&&!/[<>\s\/]/.test(t.current())&&(a=e.htmlState.tagName&&e.htmlState.tagName.toLowerCase())&&s.hasOwnProperty(a)?e.inTag=a+" ":e.inTag&&l&&/>$/.test(t.current())?(a=/^([\S]+) (.*)/.exec(e.inTag),e.inTag=null,l=">"==t.current()&&function(t,e){for(var a=0;a<t.length;a++){var n=t[a];if(!n[0]||n[1].test(d(e,n[0])))return n[2]}}(s[a[1]],a[2]),l=m.getMode(i,l),o=g(a[1],!0),r=g(a[1],!1),e.token=function(t,e){return t.match(o,!1)?(e.token=u,e.localState=e.localMode=null):(a=t,n=r,t=e.localMode.token(t,e.localState),e=a.current(),-1<(l=e.search(n))?a.backUp(e.length-l):e.match(/<\/?$/)&&(a.backUp(e.length),a.match(n,!1)||a.match(e)),t);var a,n,l},e.localMode=l,e.localState=m.startState(l,c.indent(e.htmlState,"",""))):e.inTag&&(e.inTag+=t.current(),t.eol()&&(e.inTag+=" ")),n}return{startState:function(){return{token:u,inTag:null,localMode:null,localState:null,htmlState:m.startState(c)}},copyState:function(t){var e;return t.localState&&(e=m.copyState(t.localMode,t.localState)),{token:t.token,inTag:t.inTag,localMode:t.localMode,localState:e,htmlState:m.copyState(c,t.htmlState)}},token:function(t,e){return e.token(t,e)},indent:function(t,e,a){return!t.localMode||/^\s*<\//.test(e)?c.indent(t.htmlState,e,a):t.localMode.indent?t.localMode.indent(t.localState,e,a):m.Pass},innerMode:function(t){return{state:t.localState||t.htmlState,mode:t.localMode||c}}}},"xml","javascript","css"),m.defineMIME("text/html","htmlmixed")});
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
+52
View File
@@ -0,0 +1,52 @@
.CodeMirror {
height: auto;
min-height: 500px;
border: 1px solid #dee2e6;
border-radius: 0.25rem;
font-size: 0.9rem;
font-family: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, monospace;
}
.CodeMirror-scroll {
min-height: 500px;
}
.editor-toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.125rem;
padding: 0.375rem 0.5rem;
background: #f8f9fa;
border: 1px solid #dee2e6;
border-bottom: none;
border-radius: 0.25rem 0.25rem 0 0;
}
.editor-toolbar .vr {
height: 22px;
opacity: 0.3;
}
.editor-toolbar .btn {
line-height: 1;
}
.editor-wrapper {
border-radius: 0 0 0.25rem 0.25rem;
overflow: hidden;
}
.editor-wrapper .CodeMirror {
border-top: none;
border-radius: 0;
}
.card-media-item {
transition: border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out;
border: 1px solid #dee2e6;
}
.card-media-item:hover {
border-color: #0d6efd;
box-shadow: 0 0 0 0.2rem rgba(13, 110, 253, 0.15);
}
+27
View File
@@ -0,0 +1,27 @@
/* Admin theme styles */
/* Code block styling (for guide pages) */
pre {
background: #f8f9fa;
padding: 1rem;
border-radius: 6px;
overflow-x: auto;
border: 1px solid #dee2e6;
margin-bottom: 1rem;
}
pre code {
background: none;
padding: 0;
color: #333;
font-size: 0.85rem;
line-height: 1.5;
}
code {
background: #e8e8e8;
padding: 0.15rem 0.4rem;
border-radius: 3px;
font-size: 0.9em;
color: #d63384;
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

@@ -0,0 +1,11 @@
<svg width="32" height="32" viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg">
<!-- < -->
<path d="M8 8 L3 16 L8 24" stroke="#ffffff" stroke-width="3" fill="none" stroke-linecap="round" stroke-linejoin="round"/>
<!-- / -->
<path d="M12 24 L18 8" stroke="#ffffff" stroke-width="3" stroke-linecap="round"/>
<!-- .. -->
<circle cx="22" cy="20" r="2" fill="#ffffff"/>
<circle cx="28" cy="20" r="2" fill="#ffffff"/>
</svg>

After

Width:  |  Height:  |  Size: 442 B

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 164 KiB

+94
View File
@@ -0,0 +1,94 @@
// Main application JavaScript
// This file contains general application functionality
/**
* Toggle sidebar visibility (open/close)
*/
function toggleSidebar() {
const sidebar = document.getElementById('site-sidebar');
const contentCol = sidebar ? sidebar.nextElementSibling || sidebar.parentElement.querySelector('.content-column') : null;
const btn = document.querySelector('.sidebar-toggle-btn');
const icon = btn ? btn.querySelector('i') : null;
if (!sidebar) return;
sidebar.classList.toggle('sidebar-hidden');
// Adjust content column width, toggle icon, and update aria-expanded
if (sidebar.classList.contains('sidebar-hidden')) {
if (contentCol) {
contentCol.classList.remove('col-lg-9', 'col-md-8');
contentCol.classList.add('col-12');
}
if (icon) {
icon.classList.remove('bi-layout-sidebar-inset');
icon.classList.add('bi-layout-sidebar');
}
if (btn) {
btn.setAttribute('aria-expanded', 'false');
}
sessionStorage.setItem('sidebarHidden', 'true');
} else {
if (contentCol) {
contentCol.classList.remove('col-12');
contentCol.classList.add('col-lg-9', 'col-md-8');
}
if (icon) {
icon.classList.remove('bi-layout-sidebar');
icon.classList.add('bi-layout-sidebar-inset');
}
if (btn) {
btn.setAttribute('aria-expanded', 'true');
}
sessionStorage.setItem('sidebarHidden', 'false');
}
}
/**
* Restore sidebar state from sessionStorage on page load
*/
function restoreSidebarState() {
if (sessionStorage.getItem('sidebarHidden') === 'true') {
const sidebar = document.getElementById('site-sidebar');
if (sidebar) {
toggleSidebar();
}
}
}
// Initialize application when DOM is ready
document.addEventListener('DOMContentLoaded', function() {
// Restore sidebar state
restoreSidebarState();
// Handle nested dropdowns for touch devices using event delegation
document.addEventListener('click', function(e) {
const toggle = e.target.closest('.dropdown-submenu .dropdown-toggle');
if (toggle) {
e.preventDefault();
e.stopPropagation();
const submenu = toggle.closest('.dropdown-submenu');
const dropdown = submenu.querySelector('.dropdown-menu');
// Close other submenus at the same level
const parent = submenu.parentElement;
parent.querySelectorAll('.dropdown-submenu').forEach(function(sibling) {
if (sibling !== submenu) {
var siblingMenu = sibling.querySelector('.dropdown-menu');
if (siblingMenu) siblingMenu.classList.remove('show');
}
});
// Toggle current submenu
if (dropdown) dropdown.classList.toggle('show');
return;
}
// Close all open submenus when clicking outside
document.querySelectorAll('.dropdown-submenu .dropdown-menu.show').forEach(function(menu) {
menu.classList.remove('show');
});
});
});
File diff suppressed because one or more lines are too long
@@ -0,0 +1,273 @@
(function () {
'use strict';
var textarea = document.getElementById('editor-textarea');
if (!textarea || typeof CodeMirror === 'undefined') return;
var ext = textarea.dataset.ext || 'md';
var form = document.getElementById('editor-form') || textarea.closest('form');
var modeMap = { md: 'markdown', html: 'htmlmixed', php: 'php' };
var editor = CodeMirror.fromTextArea(textarea, {
mode: modeMap[ext] || 'markdown',
lineNumbers: true,
lineWrapping: true,
matchBrackets: true,
autoCloseBrackets: true,
styleActiveLine: true,
indentUnit: 4,
tabSize: 4,
indentWithTabs: true,
viewportMargin: Infinity,
extraKeys: {
'Ctrl-S': function () { if (form) form.submit(); },
'Cmd-S': function () { if (form) form.submit(); }
}
});
// Expose editor globally so other scripts can access it
window.codeMirrorEditor = editor;
var commands = {
md: [
{ cmd: 'bold', icon: 'bi-type-bold', title: 'Vet' },
{ cmd: 'italic', icon: 'bi-type-italic', title: 'Cursief' },
{ cmd: 'heading', icon: 'bi-type-h2', title: 'Kop' },
{ cmd: 'link', icon: 'bi-link-45deg', title: 'Link' },
null,
{ cmd: 'ulist', icon: 'bi-list-ul', title: 'Ongenummerde lijst' },
{ cmd: 'olist', icon: 'bi-list-ol', title: 'Genummerde lijst' },
{ cmd: 'code', icon: 'bi-code-slash', title: 'Code' },
{ cmd: 'quote', icon: 'bi-chat-quote', title: 'Citaat' },
null,
{ cmd: 'hr', icon: 'bi-hr', title: 'Horizontale lijn' },
null,
{ cmd: 'media', icon: 'bi-images', title: 'Media invoegen' }
],
html: [
{ cmd: 'strong', icon: 'bi-type-bold', title: '<strong>' },
{ cmd: 'em', icon: 'bi-type-italic', title: '<em>' },
null,
{ cmd: 'link', icon: 'bi-link-45deg', title: 'Link' },
{ cmd: 'image', icon: 'bi-image', title: 'Afbeelding' },
null,
{ cmd: 'comment', icon: 'bi-chat-square-dots', title: 'Commentaar' },
null,
{ cmd: 'media', icon: 'bi-images', title: 'Media invoegen' }
],
php: [
{ cmd: 'comment_php', icon: 'bi-slash-circle', title: '// Commentaar' },
{ cmd: 'docblock', icon: 'bi-blockquote-left', title: '/** DocBlock */' },
null,
{ cmd: 'media', icon: 'bi-images', title: 'Media invoegen' }
]
};
function wrap(editor, before, after) {
var sel = editor.getSelection();
editor.replaceSelection(before + sel + after);
if (!sel) {
var cur = editor.getCursor();
editor.setCursor({ line: cur.line, ch: cur.ch - after.length });
}
editor.focus();
}
function insert(editor, text, cursorOffset) {
editor.replaceSelection(text);
if (cursorOffset !== undefined) {
var cur = editor.getCursor();
editor.setCursor({ line: cur.line, ch: cur.ch - text.length + cursorOffset });
}
editor.focus();
}
function prependLine(editor, prefix) {
var cur = editor.getCursor();
editor.replaceRange(prefix, { line: cur.line, ch: 0 }, { line: cur.line, ch: 0 });
editor.setCursor({ line: cur.line, ch: prefix.length });
editor.focus();
}
function execute(cmd) {
var sel = editor.getSelection();
switch (cmd) {
case 'bold':
sel ? wrap(editor, '**', '**') : insert(editor, '****', 2);
break;
case 'italic':
sel ? wrap(editor, '*', '*') : insert(editor, '**', 1);
break;
case 'heading':
prependLine(editor, '## ');
break;
case 'link':
sel ? wrap(editor, '[', '](url)') : insert(editor, '[linktekst](url)', 1);
break;
case 'ulist':
prependLine(editor, '- ');
break;
case 'olist':
prependLine(editor, '1. ');
break;
case 'code':
sel ? wrap(editor, '`', '`') : insert(editor, '``', 1);
break;
case 'quote':
prependLine(editor, '> ');
break;
case 'hr':
insert(editor, '\n---\n', 0);
break;
case 'strong':
sel ? wrap(editor, '<strong>', '</strong>') : insert(editor, '<strong></strong>', 8);
break;
case 'em':
sel ? wrap(editor, '<em>', '</em>') : insert(editor, '<em></em>', 4);
break;
case 'image':
insert(editor, '<img src="" alt="">', 10);
break;
case 'comment':
sel ? wrap(editor, '<!-- ', ' -->') : insert(editor, '<!-- -->', 4);
break;
case 'comment_php':
if (sel) {
sel.indexOf('\n') !== -1
? insert(editor, '/* ' + sel + ' */', 0)
: insert(editor, '// ' + sel, 0);
} else {
prependLine(editor, '// ');
}
break;
case 'docblock':
insert(editor, '/**\n * \n */', 7);
break;
case 'media':
var modal = new bootstrap.Modal(document.getElementById('mediaModal'));
if (modal) modal.show();
break;
}
}
function buildToolbar(ext) {
var toolbar = document.getElementById('editor-toolbar');
if (!toolbar) return;
toolbar.innerHTML = '';
var cmds = commands[ext] || [];
cmds.forEach(function (item) {
if (item === null) {
var sep = document.createElement('div');
sep.className = 'vr';
toolbar.appendChild(sep);
return;
}
var btn = document.createElement('button');
btn.type = 'button';
btn.className = 'btn btn-sm btn-outline-secondary';
btn.title = item.title;
btn.innerHTML = '<i class="bi ' + item.icon + '"></i>';
btn.addEventListener('click', function () { execute(item.cmd); });
toolbar.appendChild(btn);
});
}
var templates = {
md: '# Nieuwe Pagina\n\nSchrijf hier je inhoud...\n',
html: '<h1>Nieuwe Pagina</h1>\n<p>Dit is een HTML content pagina.</p>\n',
php: '---\ntitle: Nieuwe Pagina\n---\n\n<?php\n$pageTitle = "Nieuwe Pagina";\n?>\n\n<h1><?= $pageTitle ?></h1>\n<p>PHP content - alles wat je hier echo&#39;t wordt weergegeven.</p>\n\n<?php\n$items = [\'Item 1\', \'Item 2\', \'Item 3\'];\n?>\n<ul>\n<?php foreach ($items as $item): ?>\n <li><?= $item ?></li>\n<?php endforeach; ?>\n</ul>\n'
};
var defaultContents = {};
function getTemplate(ext) {
return templates[ext] || '';
}
function setDefaultContent(ext) {
var tpl = getTemplate(ext);
editor.setValue(tpl);
editor.setCursor({ line: 0, ch: 0 });
editor.focus();
defaultContents[ext] = tpl;
}
function switchMode(ext, forceContent) {
var mode = modeMap[ext] || 'markdown';
editor.setOption('mode', mode);
textarea.dataset.ext = ext;
if (forceContent) {
setDefaultContent(ext);
} else {
var cur = editor.getValue().trim();
var expected = defaultContents[ext];
if (cur === '' || (expected && cur === expected.trim())) {
setDefaultContent(ext);
}
}
}
editor.on('change', function () {
if (window.__onContentChange) window.__onContentChange();
});
buildToolbar(ext);
var form = document.getElementById('editor-form');
var isNewPage = form && form.hasAttribute('data-new-page');
if (isNewPage && editor.getValue().trim() === '') {
setDefaultContent(ext);
}
var typeSelect = document.querySelector('[data-editor-mode]');
if (typeSelect && isNewPage) {
typeSelect.addEventListener('change', function () {
switchMode(this.value, true);
});
}
var form = document.getElementById('editor-form') || textarea.closest('form');
if (form) {
form.addEventListener('submit', function () {
editor.save();
});
}
// Keyboard shortcuts: Ctrl/Cmd+S saves, Ctrl/Cmd+N creates a new page
function handleShortcut(e) {
var mod = e.ctrlKey || e.metaKey;
if (!mod || e.altKey) return;
var key = (e.key || '').toLowerCase();
if (key === 's') {
e.preventDefault();
editor.save();
if (form) {
if (typeof form.requestSubmit === 'function') {
form.requestSubmit();
} else {
form.submit();
}
}
return;
}
if (key === 'n') {
e.preventDefault();
window.location.href = '/admin/content-new';
}
}
document.addEventListener('keydown', handleShortcut);
// CodeMirror swallows keystrokes inside the editor, so bind there too
editor.setOption('extraKeys', Object.assign({}, editor.getOption('extraKeys') || {}, {
'Ctrl-S': function () { handleShortcut({ ctrlKey: true, key: 's', preventDefault: function () {} }); },
'Cmd-S': function () { handleShortcut({ metaKey: true, key: 's', preventDefault: function () {} }); }
}));
})();
@@ -0,0 +1,599 @@
/**
* KeyboardNavigation - WCAG 2.1 AA Compliant Keyboard Navigation
*
* Features:
* - Full keyboard navigation support
* - Focus management
* - Skip links functionality
* - Custom keyboard shortcuts
* - Focus trap for modals
* - WCAG 2.1 AA compliance
*/
class KeyboardNavigation {
constructor() {
this.focusableElements = 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])';
this.currentFocusIndex = -1;
this.focusableElementsList = [];
this.modalOpen = false;
this.lastFocusedElement = null;
this.init();
}
/**
* Initialize keyboard navigation
*/
init() {
this.setupEventListeners();
this.setupSkipLinks();
this.setupFocusManagement();
this.setupKeyboardShortcuts();
this.announceToScreenReader('Keyboard navigation initialized');
}
/**
* Setup event listeners for keyboard navigation
*/
setupEventListeners() {
document.addEventListener('keydown', (e) => this.handleKeyDown(e));
document.addEventListener('focus', (e) => this.handleFocus(e), true);
document.addEventListener('blur', (e) => this.handleBlur(e), true);
// Handle focus for dynamic content
const observer = new MutationObserver(() => {
this.updateFocusableElements();
});
observer.observe(document.body, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ['tabindex', 'disabled', 'aria-hidden']
});
}
/**
* Handle keyboard events
*
* @param {KeyboardEvent} e Keyboard event
*/
handleKeyDown(e) {
switch (e.key) {
case 'Tab':
this.handleTabNavigation(e);
break;
case 'Enter':
case ' ':
this.handleActivation(e);
break;
case 'Escape':
this.handleEscape(e);
break;
case 'ArrowUp':
case 'ArrowDown':
case 'ArrowLeft':
case 'ArrowRight':
this.handleArrowNavigation(e);
break;
case 'Home':
case 'End':
this.handleHomeEndNavigation(e);
break;
default:
this.handleCustomShortcuts(e);
}
}
/**
* Handle Tab navigation
*
* @param {KeyboardEvent} e Keyboard event
*/
handleTabNavigation(e) {
if (e.ctrlKey || e.altKey) return;
this.updateFocusableElements();
if (this.focusableElementsList.length === 0) return;
const currentIndex = this.focusableElementsList.indexOf(document.activeElement);
let nextIndex;
if (e.shiftKey) {
// Shift+Tab - Previous element
nextIndex = currentIndex <= 0 ? this.focusableElementsList.length - 1 : currentIndex - 1;
} else {
// Tab - Next element
nextIndex = currentIndex >= this.focusableElementsList.length - 1 ? 0 : currentIndex + 1;
}
e.preventDefault();
this.focusElement(this.focusableElementsList[nextIndex]);
}
/**
* Handle activation (Enter/Space)
*
* @param {KeyboardEvent} e Keyboard event
*/
handleActivation(e) {
const element = document.activeElement;
if (e.key === ' ' && (element.tagName === 'BUTTON' || element.role === 'button')) {
e.preventDefault();
element.click();
this.announceToScreenReader('Button activated');
}
if (e.key === 'Enter' && element.tagName === 'A' && element.getAttribute('role') === 'menuitem') {
e.preventDefault();
element.click();
this.announceToScreenReader('Link activated');
}
}
/**
* Handle Escape key
*
* @param {KeyboardEvent} e Keyboard event
*/
handleEscape(e) {
if (this.modalOpen) {
this.closeModal();
this.announceToScreenReader('Modal closed');
} else {
// Return focus to main content
const mainContent = document.getElementById('main-content');
if (mainContent) {
this.focusElement(mainContent);
this.announceToScreenReader('Returned to main content');
}
}
}
/**
* Handle arrow key navigation
*
* @param {KeyboardEvent} e Keyboard event
*/
handleArrowNavigation(e) {
const element = document.activeElement;
// Handle menu navigation
if (element.getAttribute('role') === 'menuitem' || element.classList.contains('dropdown-item')) {
e.preventDefault();
this.navigateMenu(e.key);
}
// Handle tab navigation in tab lists
if (element.getAttribute('role') === 'tab') {
e.preventDefault();
this.navigateTabs(e.key);
}
// Handle grid navigation
if (element.getAttribute('role') === 'gridcell') {
e.preventDefault();
this.navigateGrid(e.key);
}
}
/**
* Handle Home/End navigation
*
* @param {KeyboardEvent} e Keyboard event
*/
handleHomeEndNavigation(e) {
if (e.ctrlKey || e.altKey) return;
this.updateFocusableElements();
if (this.focusableElementsList.length === 0) return;
const targetIndex = e.key === 'Home' ? 0 : this.focusableElementsList.length - 1;
e.preventDefault();
this.focusElement(this.focusableElementsList[targetIndex]);
this.announceToScreenReader(`Moved to ${e.key === 'Home' ? 'first' : 'last'} element`);
}
/**
* Setup skip links functionality
*/
setupSkipLinks() {
const skipLinks = document.querySelectorAll('.skip-link');
skipLinks.forEach(link => {
link.addEventListener('click', (e) => {
e.preventDefault();
const targetId = link.getAttribute('href').substring(1);
const targetElement = document.getElementById(targetId);
if (targetElement) {
this.focusElement(targetElement);
this.announceToScreenReader(`Skipped to ${link.textContent}`);
}
});
});
}
/**
* Setup focus management
*/
setupFocusManagement() {
// Add focus indicators
const style = document.createElement('style');
style.textContent = `
:focus {
outline: 3px solid #0056b3 !important;
outline-offset: 2px !important;
}
.skip-link:focus {
position: static !important;
width: auto !important;
height: auto !important;
overflow: visible !important;
clip: auto !important;
clip-path: none !important;
white-space: normal !important;
}
[aria-hidden="true"] {
display: none !important;
}
.sr-only {
position: absolute !important;
width: 1px !important;
height: 1px !important;
padding: 0 !important;
margin: -1px !important;
overflow: hidden !important;
clip: rect(0, 0, 0, 0) !important;
white-space: nowrap !important;
border: 0 !important;
}
.keyboard-user *:focus {
outline: 3px solid #0056b3 !important;
outline-offset: 2px !important;
}
`;
document.head.appendChild(style);
// Detect keyboard user
document.addEventListener('keydown', () => {
document.body.classList.add('keyboard-user');
}, { once: true });
// Remove keyboard class on mouse use
document.addEventListener('mousedown', () => {
document.body.classList.remove('keyboard-user');
});
}
/**
* Setup custom keyboard shortcuts
*/
setupKeyboardShortcuts() {
// Alt+S - Focus search
this.addShortcut('Alt+s', () => {
const searchInput = document.getElementById('search-input');
if (searchInput) {
this.focusElement(searchInput);
this.announceToScreenReader('Search focused');
}
});
// Alt+N - Focus navigation
this.addShortcut('Alt+n', () => {
const navigation = document.getElementById('main-navigation');
if (navigation) {
this.focusElement(navigation.querySelector('[role="menuitem"]'));
this.announceToScreenReader('Navigation focused');
}
});
// Alt+M - Focus main content
this.addShortcut('Alt+m', () => {
const mainContent = document.getElementById('main-content');
if (mainContent) {
this.focusElement(mainContent);
this.announceToScreenReader('Main content focused');
}
});
// Alt+H - Go home
this.addShortcut('Alt+h', () => {
window.location.href = '/';
});
// Alt+1-9 - Quick navigation
for (let i = 1; i <= 9; i++) {
this.addShortcut(`Alt+${i}`, () => {
this.quickNavigate(i);
});
}
}
/**
* Add keyboard shortcut
*
* @param {string} shortcut Shortcut combination
* @param {Function} callback Callback function
*/
addShortcut(shortcut, callback) {
document.addEventListener('keydown', (e) => {
if (this.matchesShortcut(e, shortcut)) {
e.preventDefault();
callback();
}
});
}
/**
* Check if event matches shortcut
*
* @param {KeyboardEvent} e Keyboard event
* @param {string} shortcut Shortcut string
* @return {boolean} True if matches
*/
matchesShortcut(e, shortcut) {
const parts = shortcut.toLowerCase().split('+');
const key = parts.pop();
if (e.key.toLowerCase() !== key) return false;
const altRequired = parts.includes('alt');
const ctrlRequired = parts.includes('ctrl');
const shiftRequired = parts.includes('shift');
return e.altKey === altRequired &&
e.ctrlKey === ctrlRequired &&
e.shiftKey === shiftRequired;
}
/**
* Update focusable elements list
*/
updateFocusableElements() {
this.focusableElementsList = Array.from(document.querySelectorAll(this.focusableElements))
.filter(element => {
// Filter out hidden elements
const style = window.getComputedStyle(element);
return style.display !== 'none' &&
style.visibility !== 'hidden' &&
element.getAttribute('aria-hidden') !== 'true' &&
!element.disabled;
});
}
/**
* Focus element with accessibility
*
* @param {Element} element Element to focus
*/
focusElement(element) {
if (!element) return;
element.focus();
// Scroll into view if needed
element.scrollIntoView({
behavior: 'smooth',
block: 'center',
inline: 'nearest'
});
}
/**
* Handle focus events
*
* @param {FocusEvent} e Focus event
*/
handleFocus(e) {
this.currentFocusIndex = this.focusableElementsList.indexOf(e.target);
// Announce focus changes to screen readers
const announcement = this.getFocusAnnouncement(e.target);
if (announcement) {
setTimeout(() => {
this.announceToScreenReader(announcement);
}, 100);
}
}
/**
* Handle blur events
*
* @param {FocusEvent} e Blur event
*/
handleBlur(e) {
// Store last focused element for modal restoration
if (!this.modalOpen) {
this.lastFocusedElement = e.target;
}
}
/**
* Get focus announcement for screen readers
*
* @param {Element} element Focused element
* @return {string} Announcement text
*/
getFocusAnnouncement(element) {
const tagName = element.tagName.toLowerCase();
const role = element.getAttribute('role');
const label = element.getAttribute('aria-label') || element.textContent || '';
if (role === 'button') {
return `Button, ${label}`;
} else if (role === 'link') {
return `Link, ${label}`;
} else if (tagName === 'input') {
const type = element.type || 'text';
return `${type} input, ${label}`;
} else if (role === 'menuitem') {
return `Menu item, ${label}`;
} else if (role === 'tab') {
return `Tab, ${label}`;
}
return label || '';
}
/**
* Announce to screen readers
*
* @param {string} message Message to announce
*/
announceToScreenReader(message) {
const announcement = document.createElement('div');
announcement.setAttribute('role', 'status');
announcement.setAttribute('aria-live', 'polite');
announcement.className = 'sr-only';
announcement.textContent = message;
document.body.appendChild(announcement);
setTimeout(() => {
document.body.removeChild(announcement);
}, 1000);
}
/**
* Navigate menu with arrow keys
*
* @param {string} direction Arrow direction
*/
navigateMenu(direction) {
const menuItems = Array.from(document.querySelectorAll('[role="menuitem"]'));
const currentIndex = menuItems.indexOf(document.activeElement);
let nextIndex;
if (direction === 'ArrowDown' || direction === 'ArrowRight') {
nextIndex = currentIndex >= menuItems.length - 1 ? 0 : currentIndex + 1;
} else {
nextIndex = currentIndex <= 0 ? menuItems.length - 1 : currentIndex - 1;
}
this.focusElement(menuItems[nextIndex]);
}
/**
* Navigate tabs with arrow keys
*
* @param {string} direction Arrow direction
*/
navigateTabs(direction) {
const tabs = Array.from(document.querySelectorAll('[role="tab"]'));
const currentIndex = tabs.indexOf(document.activeElement);
let nextIndex;
if (direction === 'ArrowRight' || direction === 'ArrowDown') {
nextIndex = currentIndex >= tabs.length - 1 ? 0 : currentIndex + 1;
} else {
nextIndex = currentIndex <= 0 ? tabs.length - 1 : currentIndex - 1;
}
this.focusElement(tabs[nextIndex]);
tabs[nextIndex].click();
}
/**
* Navigate grid with arrow keys
*
* @param {string} direction Arrow direction
*/
navigateGrid(direction) {
// Implementation for grid navigation
// This would need to be customized based on specific grid structure
}
/**
* Quick navigation with Alt+number
*
* @param {number} number Number key
*/
quickNavigate(number) {
const targets = [
{ selector: '#main-navigation', name: 'navigation' },
{ selector: '#search-input', name: 'search' },
{ selector: '#main-content', name: 'main content' },
{ selector: 'h1', name: 'heading' },
{ selector: '.breadcrumb', name: 'breadcrumb' },
{ selector: 'footer', name: 'footer' },
{ selector: '.sidebar', name: 'sidebar' },
{ selector: '.btn-primary', name: 'primary button' },
{ selector: 'form', name: 'form' }
];
if (number <= targets.length) {
const target = targets[number - 1];
const element = document.querySelector(target.selector);
if (element) {
this.focusElement(element);
this.announceToScreenReader(`Quick navigation to ${target.name}`);
}
}
}
/**
* Handle custom shortcuts
*
* @param {KeyboardEvent} e Keyboard event
*/
handleCustomShortcuts(e) {
// Additional custom shortcuts can be added here
}
/**
* Open modal with focus trap
*
* @param {string} modalId Modal ID
*/
openModal(modalId) {
const modal = document.getElementById(modalId);
if (!modal) return;
this.modalOpen = true;
this.lastFocusedElement = document.activeElement;
modal.setAttribute('aria-hidden', 'false');
modal.style.display = 'block';
// Focus first focusable element in modal
const firstFocusable = modal.querySelector(this.focusableElements);
if (firstFocusable) {
this.focusElement(firstFocusable);
}
this.announceToScreenReader('Modal opened');
}
/**
* Close modal and restore focus
*/
closeModal() {
if (!this.modalOpen) return;
const modal = document.querySelector('[role="dialog"][aria-hidden="false"]');
if (!modal) return;
modal.setAttribute('aria-hidden', 'true');
modal.style.display = 'none';
this.modalOpen = false;
// Restore focus to last focused element
if (this.lastFocusedElement) {
this.focusElement(this.lastFocusedElement);
}
}
}
// Initialize keyboard navigation when DOM is ready
document.addEventListener('DOMContentLoaded', () => {
window.keyboardNavigation = new KeyboardNavigation();
});
@@ -0,0 +1,658 @@
/**
* ScreenReaderOptimization - WCAG 2.1 AA Compliant Screen Reader Support
*
* Features:
* - Screen reader detection and optimization
* - Live region management
* - ARIA announcements
* - Content adaptation for screen readers
* - Voice control support
* - WCAG 2.1 AA compliance
*/
class ScreenReaderOptimization {
constructor() {
this.isScreenReaderActive = false;
this.liveRegion = null;
this.announcementQueue = [];
this.isAnnouncing = false;
this.voiceControlEnabled = false;
this.init();
}
/**
* Initialize screen reader optimization
*/
init() {
this.detectScreenReader();
this.createLiveRegion();
this.setupVoiceControl();
this.optimizeContent();
this.setupEventListeners();
}
/**
* Detect if screen reader is active
*/
detectScreenReader() {
// Multiple detection methods
const methods = [
this.detectByNavigator,
this.detectByAria,
this.detectByTiming,
this.detectByBehavior
];
let positiveDetections = 0;
methods.forEach(method => {
if (method.call(this)) {
positiveDetections++;
}
});
// Consider screen reader active if majority of methods detect it
this.isScreenReaderActive = positiveDetections >= 2;
if (this.isScreenReaderActive) {
document.body.classList.add('screen-reader-active');
this.announceToScreenReader('Screen reader detected, accessibility features enabled');
}
}
/**
* Detect screen reader by navigator properties
*/
detectByNavigator() {
// Check for common screen reader indicators
return window.speechSynthesis !== undefined ||
window.navigator.userAgent.includes('JAWS') ||
window.navigator.userAgent.includes('NVDA') ||
window.navigator.userAgent.includes('VoiceOver') ||
window.navigator.userAgent.includes('TalkBack');
}
/**
* Detect screen reader by ARIA support
*/
detectByAria() {
// Check if ARIA attributes are supported and used
const testElement = document.createElement('div');
testElement.setAttribute('role', 'region');
testElement.setAttribute('aria-live', 'polite');
return testElement.getAttribute('role') === 'region' &&
testElement.getAttribute('aria-live') === 'polite';
}
/**
* Detect screen reader by timing analysis
*/
detectByTiming() {
// Screen readers often have different timing patterns
const startTime = performance.now();
// Create a test element that screen readers would process differently
const testElement = document.createElement('div');
testElement.setAttribute('aria-hidden', 'false');
testElement.textContent = 'Screen reader test';
document.body.appendChild(testElement);
const endTime = performance.now();
document.body.removeChild(testElement);
// If processing takes unusually long, might indicate screen reader
return (endTime - startTime) > 50;
}
/**
* Detect screen reader by user behavior
*/
detectByBehavior() {
// Check for keyboard-only navigation patterns
let keyboardOnly = true;
document.addEventListener('mousedown', () => {
keyboardOnly = false;
}, { once: true });
// If user navigates with keyboard extensively, likely screen reader user
setTimeout(() => {
if (keyboardOnly) {
this.isScreenReaderActive = true;
}
}, 5000);
return false; // Async detection
}
/**
* Create live region for announcements
*/
createLiveRegion() {
this.liveRegion = document.createElement('div');
this.liveRegion.setAttribute('aria-live', 'polite');
this.liveRegion.setAttribute('aria-atomic', 'true');
this.liveRegion.className = 'sr-only live-region';
this.liveRegion.style.position = 'absolute';
this.liveRegion.style.left = '-10000px';
this.liveRegion.style.width = '1px';
this.liveRegion.style.height = '1px';
this.liveRegion.style.overflow = 'hidden';
document.body.appendChild(this.liveRegion);
}
/**
* Setup voice control
*/
setupVoiceControl() {
if ('webkitSpeechRecognition' in window || 'SpeechRecognition' in window) {
this.voiceControlEnabled = true;
this.initializeVoiceRecognition();
}
}
/**
* Initialize voice recognition
*/
initializeVoiceRecognition() {
const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
this.recognition = new SpeechRecognition();
this.recognition.continuous = false;
this.recognition.interimResults = false;
this.recognition.lang = document.documentElement.lang || 'nl-NL';
this.recognition.onresult = (event) => {
const command = event.results[0][0].transcript.toLowerCase();
this.handleVoiceCommand(command);
};
this.recognition.onerror = (event) => {
console.log('Voice recognition error:', event.error);
};
}
/**
* Handle voice commands
*
* @param {string} command Voice command
*/
handleVoiceCommand(command) {
const commands = {
'zoeken': () => this.focusSearch(),
'navigatie': () => this.focusNavigation(),
'hoofdinhoud': () => this.focusMainContent(),
'home': () => this.goHome(),
'terug': () => this.goBack(),
'volgende': () => this.goNext(),
'vorige': () => this.goPrevious(),
'stop': () => this.stopReading()
};
for (const [keyword, action] of Object.entries(commands)) {
if (command.includes(keyword)) {
action();
this.announceToScreenReader(`Voice command: ${keyword}`);
break;
}
}
}
/**
* Optimize content for screen readers
*/
optimizeContent() {
this.addMissingLabels();
this.enhanceHeadings();
this.improveTableAccessibility();
this.optimizeImages();
this.enhanceLinks();
this.addLandmarks();
}
/**
* Add missing labels to form elements
*/
addMissingLabels() {
const inputs = document.querySelectorAll('input, select, textarea');
inputs.forEach(input => {
if (!input.getAttribute('aria-label') && !input.getAttribute('aria-labelledby')) {
const id = input.id || 'input-' + Math.random().toString(36).substr(2, 9);
input.id = id;
// Try to find associated label
let label = document.querySelector(`label[for="${id}"]`);
if (!label) {
// Create label from placeholder or name
const labelText = input.placeholder || input.name || input.type || 'Input';
label = document.createElement('label');
label.textContent = labelText;
label.setAttribute('for', id);
label.className = 'sr-only';
input.parentNode.insertBefore(label, input);
}
input.setAttribute('aria-label', label.textContent);
}
});
}
/**
* Enhance headings for better structure
*/
enhanceHeadings() {
const headings = document.querySelectorAll('h1, h2, h3, h4, h5, h6');
headings.forEach((heading, index) => {
// Add proper ARIA attributes
heading.setAttribute('role', 'heading');
heading.setAttribute('aria-level', heading.tagName.substring(1));
// Add unique ID for navigation
if (!heading.id) {
heading.id = 'heading-' + index;
}
// Add heading anchor for navigation
if (!heading.querySelector('.heading-anchor')) {
const anchor = document.createElement('a');
anchor.href = '#' + heading.id;
anchor.className = 'heading-anchor sr-only';
anchor.textContent = 'Link to this heading';
anchor.setAttribute('aria-label', 'Link to this heading');
heading.appendChild(anchor);
}
});
}
/**
* Improve table accessibility
*/
improveTableAccessibility() {
const tables = document.querySelectorAll('table');
tables.forEach(table => {
// Add table caption if missing
if (!table.querySelector('caption')) {
const caption = document.createElement('caption');
caption.textContent = 'Tabel ' + (tables.indexOf(table) + 1);
caption.className = 'sr-only';
table.insertBefore(caption, table.firstChild);
}
// Add scope to headers
const headers = table.querySelectorAll('th');
headers.forEach(header => {
if (!header.getAttribute('scope')) {
const scope = header.parentElement.tagName === 'THEAD' ? 'col' : 'row';
header.setAttribute('scope', scope);
}
});
// Add table description
if (!table.getAttribute('aria-describedby')) {
const description = document.createElement('div');
description.id = 'table-desc-' + Math.random().toString(36).substr(2, 9);
description.className = 'sr-only';
description.textContent = 'Data table with ' + headers.length + ' columns';
table.parentNode.insertBefore(description, table);
table.setAttribute('aria-describedby', description.id);
}
});
}
/**
* Optimize images for screen readers
*/
optimizeImages() {
const images = document.querySelectorAll('img');
images.forEach(img => {
// Ensure alt text exists
if (!img.alt && !img.getAttribute('aria-label')) {
// Try to get alt text from nearby text
const nearbyText = this.getNearbyText(img);
img.alt = nearbyText || 'Afbeelding';
img.setAttribute('role', 'img');
}
// Add long description if needed
if (img.title && !img.getAttribute('aria-describedby')) {
const descId = 'img-desc-' + Math.random().toString(36).substr(2, 9);
const description = document.createElement('div');
description.id = descId;
description.className = 'sr-only';
description.textContent = img.title;
img.parentNode.insertBefore(description, img.nextSibling);
img.setAttribute('aria-describedby', descId);
}
});
}
/**
* Enhance links for screen readers
*/
enhanceLinks() {
const links = document.querySelectorAll('a');
links.forEach(link => {
// Ensure accessible name
if (!link.textContent.trim() && !link.getAttribute('aria-label')) {
const href = link.getAttribute('href') || '';
link.setAttribute('aria-label', 'Link: ' + href);
}
// Add external link indication
if (link.hostname !== window.location.hostname) {
if (!link.getAttribute('aria-label')?.includes('external')) {
const currentLabel = link.getAttribute('aria-label') || link.textContent;
link.setAttribute('aria-label', currentLabel + ' (externe link)');
}
}
// Add file type and size for file links
const href = link.getAttribute('href');
if (href && this.isFileLink(href)) {
const fileInfo = this.getFileInfo(href);
if (!link.getAttribute('aria-label')?.includes(fileInfo.type)) {
const currentLabel = link.getAttribute('aria-label') || link.textContent;
link.setAttribute('aria-label', currentLabel + ` (${fileInfo.type}, ${fileInfo.size})`);
}
}
});
}
/**
* Add landmarks for better navigation
*/
addLandmarks() {
// Add main landmark if missing
if (!document.querySelector('[role="main"], main')) {
const content = document.querySelector('article, .content, #content');
if (content) {
content.setAttribute('role', 'main');
content.id = 'main-content';
}
}
// Add navigation landmark if missing
if (!document.querySelector('[role="navigation"], nav')) {
const nav = document.querySelector('.nav, .navigation, #navigation');
if (nav) {
nav.setAttribute('role', 'navigation');
nav.setAttribute('aria-label', 'Hoofdmenu');
}
}
// Add search landmark if missing
if (!document.querySelector('[role="search"]')) {
const search = document.querySelector('.search, #search, [type="search"]');
if (search) {
search.setAttribute('role', 'search');
search.setAttribute('aria-label', 'Zoeken');
}
}
// Add contentinfo landmark if missing
if (!document.querySelector('[role="contentinfo"], footer')) {
const footer = document.querySelector('footer');
if (footer) {
footer.setAttribute('role', 'contentinfo');
footer.setAttribute('aria-label', 'Voettekst');
}
}
}
/**
* Setup event listeners for dynamic content
*/
setupEventListeners() {
// Monitor DOM changes for new content
const observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
if (mutation.type === 'childList') {
mutation.addedNodes.forEach((node) => {
if (node.nodeType === Node.ELEMENT_NODE) {
this.optimizeNode(node);
}
});
}
});
});
observer.observe(document.body, {
childList: true,
subtree: true
});
// Handle page changes
window.addEventListener('popstate', () => {
setTimeout(() => this.optimizeContent(), 100);
});
// Handle AJAX content loading
window.addEventListener('load', () => {
setTimeout(() => this.optimizeContent(), 100);
});
}
/**
* Optimize a specific node
*
* @param {Node} node Node to optimize
*/
optimizeNode(node) {
if (node.nodeType !== Node.ELEMENT_NODE) return;
// Optimize based on tag type
switch (node.tagName.toLowerCase()) {
case 'img':
this.optimizeImages();
break;
case 'a':
this.enhanceLinks();
break;
case 'table':
this.improveTableAccessibility();
break;
case 'h1':
case 'h2':
case 'h3':
case 'h4':
case 'h5':
case 'h6':
this.enhanceHeadings();
break;
case 'input':
case 'select':
case 'textarea':
this.addMissingLabels();
break;
}
}
/**
* Get nearby text for an element
*
* @param {Element} element Element to check
* @return {string} Nearby text
*/
getNearbyText(element) {
// Check parent text content
let parent = element.parentElement;
if (parent) {
const text = parent.textContent.replace(element.alt || '', '').trim();
if (text) return text;
}
// Check previous sibling
let prev = element.previousElementSibling;
if (prev && prev.textContent.trim()) {
return prev.textContent.trim();
}
// Check next sibling
let next = element.nextElementSibling;
if (next && next.textContent.trim()) {
return next.textContent.trim();
}
return '';
}
/**
* Check if link is a file link
*
* @param {string} href Link href
* @return {boolean} True if file link
*/
isFileLink(href) {
const fileExtensions = ['.pdf', '.doc', '.docx', '.xls', '.xlsx', '.ppt', '.pptx', '.zip', '.rar'];
return fileExtensions.some(ext => href.toLowerCase().includes(ext));
}
/**
* Get file information
*
* @param {string} href File link
* @return {object} File information
*/
getFileInfo(href) {
const extension = href.split('.').pop().toLowerCase();
const types = {
'pdf': { type: 'PDF document', size: '' },
'doc': { type: 'Word document', size: '' },
'docx': { type: 'Word document', size: '' },
'xls': { type: 'Excel spreadsheet', size: '' },
'xlsx': { type: 'Excel spreadsheet', size: '' },
'ppt': { type: 'PowerPoint presentation', size: '' },
'pptx': { type: 'PowerPoint presentation', size: '' },
'zip': { type: 'ZIP archive', size: '' },
'rar': { type: 'RAR archive', size: '' }
};
return types[extension] || { type: 'File', size: '' };
}
/**
* Announce message to screen readers
*
* @param {string} message Message to announce
* @param {string} priority Priority level
*/
announceToScreenReader(message, priority = 'polite') {
if (!this.isScreenReaderActive) return;
// Queue announcement if currently announcing
if (this.isAnnouncing) {
this.announcementQueue.push({ message, priority });
return;
}
this.isAnnouncing = true;
// Create temporary live region if needed
const tempRegion = document.createElement('div');
tempRegion.setAttribute('aria-live', priority);
tempRegion.setAttribute('aria-atomic', 'true');
tempRegion.className = 'sr-only';
tempRegion.textContent = message;
document.body.appendChild(tempRegion);
// Remove after announcement
setTimeout(() => {
document.body.removeChild(tempRegion);
this.isAnnouncing = false;
// Process next announcement in queue
if (this.announcementQueue.length > 0) {
const next = this.announcementQueue.shift();
this.announceToScreenReader(next.message, next.priority);
}
}, 1000);
}
/**
* Voice control methods
*/
startVoiceRecognition() {
if (this.voiceControlEnabled && this.recognition) {
this.recognition.start();
this.announceToScreenReader('Voice control activated');
}
}
stopVoiceRecognition() {
if (this.voiceControlEnabled && this.recognition) {
this.recognition.stop();
this.announceToScreenReader('Voice control deactivated');
}
}
/**
* Navigation methods for voice control
*/
focusSearch() {
const searchInput = document.getElementById('search-input');
if (searchInput) {
searchInput.focus();
this.announceToScreenReader('Search focused');
}
}
focusNavigation() {
const navigation = document.querySelector('[role="navigation"]');
if (navigation) {
navigation.focus();
this.announceToScreenReader('Navigation focused');
}
}
focusMainContent() {
const mainContent = document.getElementById('main-content');
if (mainContent) {
mainContent.focus();
this.announceToScreenReader('Main content focused');
}
}
goHome() {
window.location.href = '/';
}
goBack() {
window.history.back();
}
goNext() {
// Implementation depends on context
const nextButton = document.querySelector('.next, [aria-label*="next"]');
if (nextButton) {
nextButton.click();
}
}
goPrevious() {
// Implementation depends on context
const prevButton = document.querySelector('.previous, [aria-label*="previous"]');
if (prevButton) {
prevButton.click();
}
}
stopReading() {
// Stop any ongoing screen reader activity
window.speechSynthesis.cancel();
this.announceToScreenReader('Reading stopped');
}
}
// Initialize screen reader optimization when DOM is ready
document.addEventListener('DOMContentLoaded', () => {
window.screenReaderOptimization = new ScreenReaderOptimization();
});