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) => {
if (!dragging) return;
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
// normale Taps, die danach zurückgesetzt werden müssten.
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 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"
aria-labelledby="shared-modal-title">
<div class="modal-panel__header">
+3
View File
@@ -59,6 +59,9 @@
<script src="/lucide.min.js"></script>
</head>
<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 -->
<div id="app" class="app-shell">
<!-- Skeleton-Loading während Initialisierung -->
+27 -2
View File
@@ -313,7 +313,8 @@ function renderAppShell(container) {
logoSvg.setAttribute('fill', 'none');
const defs = document.createElementNS(SVG_NS, 'defs');
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('x2', '160'); grad.setAttribute('y2', '160');
grad.setAttribute('gradientUnits', 'userSpaceOnUse');
@@ -328,7 +329,7 @@ function renderAppShell(container) {
logoSvg.appendChild(defs);
const bgRect = document.createElementNS(SVG_NS, 'rect');
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);
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');
@@ -528,17 +529,41 @@ function initSearch(container) {
const results = container.querySelector('#search-results');
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() {
if (window._closeMoreSheet) window._closeMoreSheet();
overlay.setAttribute('aria-hidden', 'false');
overlay.classList.add('search-overlay--visible');
setTimeout(() => input.focus(), 50);
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() {
overlay.setAttribute('aria-hidden', 'true');
overlay.classList.remove('search-overlay--visible');
if (_searchTrapHandler) {
overlay.removeEventListener('keydown', _searchTrapHandler);
_searchTrapHandler = null;
}
input.value = '';
results.replaceChildren();
}
+3 -5
View File
@@ -1990,13 +1990,11 @@
button i[data-lucide],
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 {
padding: var(--space-1);
min-height: unset;
min-width: unset;
width: 36px;
height: 36px;
min-height: var(--target-base);
min-width: var(--target-base);
}
/* Textarea: vertikale Größenänderung ist nutzbar */
+4 -3
View File
@@ -307,9 +307,10 @@
/* --------------------------------------------------------
* 11b. Touch-Target Sizes
* -------------------------------------------------------- */
--target-sm: 32px;
--target-md: 40px;
--target-lg: 48px;
--target-sm: 32px; /* Visuelle Größe (z.B. Logos) — kein Touch-Target */
--target-md: 40px; /* Desktop Touch-Target (Maus) */
--target-lg: 48px; /* Mobile Touch-Target (Finger) */
--target-base: 44px; /* iOS-Minimum Touch-Target (44pt) */
/* --------------------------------------------------------
* 12. Layout