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