fix: UX & accessibility improvements (7 fixes)

- Add --target-base token (44px) for search close button
- Correct touch targets: btn--icon-sm 36→44px min-size
- Remove role=presentation from modal overlay
- Fix modal swipe-to-close stuck state on upswing
- Add focus trap to search overlay
- Generate unique SVG gradient IDs to prevent collisions
- Add skip-to-content link for keyboard/screen reader users
This commit is contained in:
Ulas Kalayci
2026-04-25 22:27:25 +02:00
5 changed files with 39 additions and 12 deletions
+2 -2
View File
@@ -131,7 +131,7 @@ function _wireSheetSwipe(panel) {
panel.addEventListener('touchmove', (e) => { panel.addEventListener('touchmove', (e) => {
if (!dragging) return; if (!dragging) return;
const dy = e.touches[0].clientY - startY; const dy = e.touches[0].clientY - startY;
if (dy < 0) { dragging = false; return; } // Aufwärts-Scroll: Swipe abbrechen if (dy < 0) { panel.style.transform = 'translateY(0)'; return; } // Aufwärts: Panel zurücksetzen, dragging bleibt aktiv
// Erst ab 10px Bewegung animieren: Verhindert winzige Transforms durch // Erst ab 10px Bewegung animieren: Verhindert winzige Transforms durch
// normale Taps, die danach zurückgesetzt werden müssten. // normale Taps, die danach zurückgesetzt werden müssten.
if (dy > 10) panel.style.transform = `translateY(${(dy - 10) * 0.6}px)`; if (dy > 10) panel.style.transform = `translateY(${(dy - 10) * 0.6}px)`;
@@ -218,7 +218,7 @@ export function openModal({ title, content, onSave, onDelete, size = 'md' } = {}
const sizeClass = size !== 'md' ? ` modal-panel--${size}` : ''; const sizeClass = size !== 'md' ? ` modal-panel--${size}` : '';
const html = ` const html = `
<div class="modal-overlay" id="shared-modal-overlay" aria-label="${t('modal.overlayLabel')}" role="presentation"> <div class="modal-overlay" id="shared-modal-overlay" aria-label="${t('modal.overlayLabel')}">
<div class="modal-panel${sizeClass}" role="dialog" aria-modal="true" <div class="modal-panel${sizeClass}" role="dialog" aria-modal="true"
aria-labelledby="shared-modal-title"> aria-labelledby="shared-modal-title">
<div class="modal-panel__header"> <div class="modal-panel__header">
+3
View File
@@ -59,6 +59,9 @@
<script src="/lucide.min.js"></script> <script src="/lucide.min.js"></script>
</head> </head>
<body> <body>
<!-- Skip-Link: sichtbar bei Keyboard-Fokus, verknüpft mit <main id="main-content"> -->
<a href="#main-content" class="sr-only">Zum Hauptinhalt springen</a>
<!-- App-Shell - wird durch JavaScript gefüllt --> <!-- App-Shell - wird durch JavaScript gefüllt -->
<div id="app" class="app-shell"> <div id="app" class="app-shell">
<!-- Skeleton-Loading während Initialisierung --> <!-- Skeleton-Loading während Initialisierung -->
+27 -2
View File
@@ -313,7 +313,8 @@ function renderAppShell(container) {
logoSvg.setAttribute('fill', 'none'); logoSvg.setAttribute('fill', 'none');
const defs = document.createElementNS(SVG_NS, 'defs'); const defs = document.createElementNS(SVG_NS, 'defs');
const grad = document.createElementNS(SVG_NS, 'linearGradient'); const grad = document.createElementNS(SVG_NS, 'linearGradient');
grad.setAttribute('id', 'oikos-logo-bg'); const gradId = `oikos-logo-bg-${Math.random().toString(36).slice(2, 7)}`;
grad.setAttribute('id', gradId);
grad.setAttribute('x1', '0'); grad.setAttribute('y1', '0'); grad.setAttribute('x1', '0'); grad.setAttribute('y1', '0');
grad.setAttribute('x2', '160'); grad.setAttribute('y2', '160'); grad.setAttribute('x2', '160'); grad.setAttribute('y2', '160');
grad.setAttribute('gradientUnits', 'userSpaceOnUse'); grad.setAttribute('gradientUnits', 'userSpaceOnUse');
@@ -328,7 +329,7 @@ function renderAppShell(container) {
logoSvg.appendChild(defs); logoSvg.appendChild(defs);
const bgRect = document.createElementNS(SVG_NS, 'rect'); const bgRect = document.createElementNS(SVG_NS, 'rect');
bgRect.setAttribute('width', '160'); bgRect.setAttribute('height', '160'); bgRect.setAttribute('width', '160'); bgRect.setAttribute('height', '160');
bgRect.setAttribute('rx', '36'); bgRect.setAttribute('fill', 'url(#oikos-logo-bg)'); bgRect.setAttribute('rx', '36'); bgRect.setAttribute('fill', `url(#${gradId})`);
logoSvg.appendChild(bgRect); logoSvg.appendChild(bgRect);
const housePath = document.createElementNS(SVG_NS, 'path'); const housePath = document.createElementNS(SVG_NS, 'path');
housePath.setAttribute('d', 'M80 36L36 72V120C36 122.2 37.8 124 40 124H68V96H92V124H120C122.2 124 124 122.2 124 120V72L80 36Z'); housePath.setAttribute('d', 'M80 36L36 72V120C36 122.2 37.8 124 40 124H68V96H92V124H120C122.2 124 124 122.2 124 120V72L80 36Z');
@@ -528,17 +529,41 @@ function initSearch(container) {
const results = container.querySelector('#search-results'); const results = container.querySelector('#search-results');
if (!searchBtn || !overlay || !input || !results) return; if (!searchBtn || !overlay || !input || !results) return;
// Leichtgewichtiger Focus Trap für das Search Overlay.
// Eigenständig (kein modal.js), da modul-globale Variablen in modal.js
// bei gleichzeitig offenem Modal überschrieben würden.
let _searchTrapHandler = null;
function openSearch() { function openSearch() {
if (window._closeMoreSheet) window._closeMoreSheet(); if (window._closeMoreSheet) window._closeMoreSheet();
overlay.setAttribute('aria-hidden', 'false'); overlay.setAttribute('aria-hidden', 'false');
overlay.classList.add('search-overlay--visible'); overlay.classList.add('search-overlay--visible');
setTimeout(() => input.focus(), 50); setTimeout(() => input.focus(), 50);
if (window.lucide) window.lucide.createIcons(); if (window.lucide) window.lucide.createIcons();
const FOCUSABLE = 'a[href],button:not([disabled]),input,select,textarea,[tabindex]:not([tabindex="-1"])';
_searchTrapHandler = (e) => {
if (e.key !== 'Tab') return;
const focusable = Array.from(overlay.querySelectorAll(FOCUSABLE));
if (!focusable.length) return;
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (e.shiftKey && document.activeElement === first) {
e.preventDefault(); last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault(); first.focus();
}
};
overlay.addEventListener('keydown', _searchTrapHandler);
} }
function closeSearch() { function closeSearch() {
overlay.setAttribute('aria-hidden', 'true'); overlay.setAttribute('aria-hidden', 'true');
overlay.classList.remove('search-overlay--visible'); overlay.classList.remove('search-overlay--visible');
if (_searchTrapHandler) {
overlay.removeEventListener('keydown', _searchTrapHandler);
_searchTrapHandler = null;
}
input.value = ''; input.value = '';
results.replaceChildren(); results.replaceChildren();
} }
+3 -5
View File
@@ -1990,13 +1990,11 @@
button i[data-lucide], button i[data-lucide],
button svg { pointer-events: none; } button svg { pointer-events: none; }
/* Kompakter Icon-Button (36×36) für Icons in engen Listenkontexten */ /* Kompakter Icon-Button: 44px Klickfläche, optisch kompakt durch geringes Padding */
.btn--icon-sm { .btn--icon-sm {
padding: var(--space-1); padding: var(--space-1);
min-height: unset; min-height: var(--target-base);
min-width: unset; min-width: var(--target-base);
width: 36px;
height: 36px;
} }
/* Textarea: vertikale Größenänderung ist nutzbar */ /* Textarea: vertikale Größenänderung ist nutzbar */
+4 -3
View File
@@ -307,9 +307,10 @@
/* -------------------------------------------------------- /* --------------------------------------------------------
* 11b. Touch-Target Sizes * 11b. Touch-Target Sizes
* -------------------------------------------------------- */ * -------------------------------------------------------- */
--target-sm: 32px; --target-sm: 32px; /* Visuelle Größe (z.B. Logos) — kein Touch-Target */
--target-md: 40px; --target-md: 40px; /* Desktop Touch-Target (Maus) */
--target-lg: 48px; --target-lg: 48px; /* Mobile Touch-Target (Finger) */
--target-base: 44px; /* iOS-Minimum Touch-Target (44pt) */
/* -------------------------------------------------------- /* --------------------------------------------------------
* 12. Layout * 12. Layout