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:
@@ -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
+2078
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
@@ -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);
|
||||
}
|
||||
@@ -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.
Binary file not shown.
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 |
@@ -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'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();
|
||||
});
|
||||
Reference in New Issue
Block a user