fix(ux): improve microinteractions across the app
1. Nav-item tap: smooth scale transition instead of abrupt snap 2. Custom toggle switch: iOS-style toggle replaces native checkboxes 3. Focus-visible: outline on cards, buttons, FABs for keyboard users 4. Empty-state: gentle fade-in animation 5. Toast icons: SVG icons for success/danger/warning types 6. Swipe haptic: vibrate(15) fires at threshold during touchmove
This commit is contained in:
@@ -377,9 +377,10 @@ function openBudgetModal({ mode, entry = null }) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="allday-toggle">
|
<label class="toggle">
|
||||||
<input type="checkbox" id="bm-recurring" ${isEdit && entry.is_recurring ? 'checked' : ''}>
|
<input type="checkbox" id="bm-recurring" ${isEdit && entry.is_recurring ? 'checked' : ''}>
|
||||||
<span class="allday-toggle__label">${t('budget.recurringLabel')}</span>
|
<span class="toggle__track"></span>
|
||||||
|
<span>${t('budget.recurringLabel')}</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -795,9 +795,10 @@ function buildEventModalContent({ mode, event, date }) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="allday-toggle">
|
<label class="toggle">
|
||||||
<input type="checkbox" id="modal-allday" ${isEdit && event.all_day ? 'checked' : ''}>
|
<input type="checkbox" id="modal-allday" ${isEdit && event.all_day ? 'checked' : ''}>
|
||||||
<span class="allday-toggle__label">${t('calendar.allDayToggle')}</span>
|
<span class="toggle__track"></span>
|
||||||
|
<span>${t('calendar.allDayToggle')}</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -379,9 +379,10 @@ function openNoteModal({ mode, note = null }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="allday-toggle">
|
<label class="toggle">
|
||||||
<input type="checkbox" id="note-pinned" ${isEdit && note.pinned ? 'checked' : ''}>
|
<input type="checkbox" id="note-pinned" ${isEdit && note.pinned ? 'checked' : ''}>
|
||||||
<span class="allday-toggle__label">${t('notes.pinnedLabel')}</span>
|
<span class="toggle__track"></span>
|
||||||
|
<span>${t('notes.pinnedLabel')}</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -350,6 +350,7 @@ function wireSwipeGestures(container) {
|
|||||||
let startX = 0, startY = 0;
|
let startX = 0, startY = 0;
|
||||||
let dx = 0;
|
let dx = 0;
|
||||||
let locked = false; // false | 'swipe' | 'scroll'
|
let locked = false; // false | 'swipe' | 'scroll'
|
||||||
|
let thresholdHit = false;
|
||||||
const card = row.querySelector('.shopping-item');
|
const card = row.querySelector('.shopping-item');
|
||||||
if (!card) return;
|
if (!card) return;
|
||||||
|
|
||||||
@@ -367,6 +368,7 @@ function wireSwipeGestures(container) {
|
|||||||
startY = e.touches[0].clientY;
|
startY = e.touches[0].clientY;
|
||||||
dx = 0;
|
dx = 0;
|
||||||
locked = false;
|
locked = false;
|
||||||
|
thresholdHit = false;
|
||||||
card.style.transition = '';
|
card.style.transition = '';
|
||||||
}, { passive: true });
|
}, { passive: true });
|
||||||
|
|
||||||
@@ -408,6 +410,12 @@ function wireSwipeGestures(container) {
|
|||||||
row.querySelector('.swipe-reveal--delete').style.opacity = String(progress);
|
row.querySelector('.swipe-reveal--delete').style.opacity = String(progress);
|
||||||
row.querySelector('.swipe-reveal--done').style.opacity = '0';
|
row.querySelector('.swipe-reveal--done').style.opacity = '0';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Haptic-Feedback beim Erreichen des Schwellwerts
|
||||||
|
if (!thresholdHit && Math.abs(dx) >= SWIPE_THRESHOLD) {
|
||||||
|
thresholdHit = true;
|
||||||
|
vibrate(15);
|
||||||
|
}
|
||||||
}, { passive: false });
|
}, { passive: false });
|
||||||
|
|
||||||
row.addEventListener('touchend', async () => {
|
row.addEventListener('touchend', async () => {
|
||||||
|
|||||||
@@ -730,6 +730,7 @@ function wireSwipeGestures(container) {
|
|||||||
let startX = 0, startY = 0;
|
let startX = 0, startY = 0;
|
||||||
let dx = 0;
|
let dx = 0;
|
||||||
let locked = false; // false = unentschieden, 'swipe' | 'scroll'
|
let locked = false; // false = unentschieden, 'swipe' | 'scroll'
|
||||||
|
let thresholdHit = false; // Haptic-Feedback am Threshold nur einmal
|
||||||
const card = row.querySelector('.task-card');
|
const card = row.querySelector('.task-card');
|
||||||
if (!card) return;
|
if (!card) return;
|
||||||
|
|
||||||
@@ -749,6 +750,7 @@ function wireSwipeGestures(container) {
|
|||||||
startY = e.touches[0].clientY;
|
startY = e.touches[0].clientY;
|
||||||
dx = 0;
|
dx = 0;
|
||||||
locked = false;
|
locked = false;
|
||||||
|
thresholdHit = false;
|
||||||
card.style.transition = '';
|
card.style.transition = '';
|
||||||
}, { passive: true });
|
}, { passive: true });
|
||||||
|
|
||||||
@@ -794,6 +796,12 @@ function wireSwipeGestures(container) {
|
|||||||
row.querySelector('.swipe-reveal--edit').style.opacity = String(progress);
|
row.querySelector('.swipe-reveal--edit').style.opacity = String(progress);
|
||||||
row.querySelector('.swipe-reveal--done').style.opacity = '0';
|
row.querySelector('.swipe-reveal--done').style.opacity = '0';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Haptic-Feedback beim Erreichen des Schwellwerts
|
||||||
|
if (!thresholdHit && Math.abs(dx) >= SWIPE_THRESHOLD) {
|
||||||
|
thresholdHit = true;
|
||||||
|
vibrate(15);
|
||||||
|
}
|
||||||
}, { passive: false });
|
}, { passive: false });
|
||||||
|
|
||||||
row.addEventListener('touchend', async () => {
|
row.addEventListener('touchend', async () => {
|
||||||
|
|||||||
+13
-1
@@ -391,15 +391,27 @@ function renderError(container, err) {
|
|||||||
* @param {'default'|'success'|'danger'|'warning'} type
|
* @param {'default'|'success'|'danger'|'warning'} type
|
||||||
* @param {number} duration - ms
|
* @param {number} duration - ms
|
||||||
*/
|
*/
|
||||||
|
const TOAST_ICONS = {
|
||||||
|
success: '<svg class="toast__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true"><polyline points="20 6 9 17 4 12"/></svg>',
|
||||||
|
danger: '<svg class="toast__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>',
|
||||||
|
warning: '<svg class="toast__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true"><path d="M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>',
|
||||||
|
};
|
||||||
|
|
||||||
function showToast(message, type = 'default', duration = 3000) {
|
function showToast(message, type = 'default', duration = 3000) {
|
||||||
const container = document.getElementById('toast-container');
|
const container = document.getElementById('toast-container');
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
|
|
||||||
const toast = document.createElement('div');
|
const toast = document.createElement('div');
|
||||||
toast.className = `toast ${type !== 'default' ? `toast--${type}` : ''}`;
|
toast.className = `toast ${type !== 'default' ? `toast--${type}` : ''}`;
|
||||||
toast.textContent = message;
|
|
||||||
toast.setAttribute('role', 'alert');
|
toast.setAttribute('role', 'alert');
|
||||||
|
|
||||||
|
// Icon: statische SVGs aus TOAST_ICONS (kein User-Input, kein XSS-Risiko)
|
||||||
|
const icon = TOAST_ICONS[type] || '';
|
||||||
|
const span = document.createElement('span');
|
||||||
|
span.textContent = message;
|
||||||
|
toast.innerHTML = icon; // eslint-disable-line no-unsanitized/property -- static SVG only
|
||||||
|
toast.appendChild(span);
|
||||||
|
|
||||||
container.appendChild(toast);
|
container.appendChild(toast);
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
toast.classList.add('toast--out');
|
toast.classList.add('toast--out');
|
||||||
|
|||||||
@@ -555,19 +555,6 @@
|
|||||||
transform: scale(1.1);
|
transform: scale(1.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Allday-Toggle */
|
|
||||||
.allday-toggle {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--space-3);
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.allday-toggle__label {
|
|
||||||
font-size: var(--text-sm);
|
|
||||||
color: var(--color-text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* --------------------------------------------------------
|
/* --------------------------------------------------------
|
||||||
* Termin-Detailansicht (Popup beim Klick)
|
* Termin-Detailansicht (Popup beim Klick)
|
||||||
* -------------------------------------------------------- */
|
* -------------------------------------------------------- */
|
||||||
|
|||||||
+109
-1
@@ -179,7 +179,7 @@
|
|||||||
gap: 2px;
|
gap: 2px;
|
||||||
padding: var(--space-1) var(--space-1);
|
padding: var(--space-1) var(--space-1);
|
||||||
color: var(--color-text-tertiary);
|
color: var(--color-text-tertiary);
|
||||||
transition: color var(--transition-fast);
|
transition: color var(--transition-fast), transform 0.12s ease;
|
||||||
-webkit-tap-highlight-color: transparent;
|
-webkit-tap-highlight-color: transparent;
|
||||||
min-height: var(--target-lg);
|
min-height: var(--target-lg);
|
||||||
min-width: var(--target-lg);
|
min-width: var(--target-lg);
|
||||||
@@ -188,6 +188,7 @@
|
|||||||
|
|
||||||
.nav-item:active {
|
.nav-item:active {
|
||||||
transform: scale(0.92);
|
transform: scale(0.92);
|
||||||
|
transition-duration: 0.06s;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Nav-Item: Aktiv ── */
|
/* ── Nav-Item: Aktiv ── */
|
||||||
@@ -242,6 +243,11 @@
|
|||||||
transform: scale(0.92);
|
transform: scale(0.92);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.page-fab:focus-visible {
|
||||||
|
outline: 2px solid #fff;
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
/* Desktop: FAB Position anpassen (keine Bottom-Nav) und etwas kleiner */
|
/* Desktop: FAB Position anpassen (keine Bottom-Nav) und etwas kleiner */
|
||||||
@media (min-width: 1024px) {
|
@media (min-width: 1024px) {
|
||||||
.page-fab {
|
.page-fab {
|
||||||
@@ -557,6 +563,11 @@
|
|||||||
transform: scale(0.99);
|
transform: scale(0.99);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.card--interactive:focus-visible {
|
||||||
|
outline: 2px solid var(--active-module-accent, var(--color-accent));
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
@media (min-width: 1024px) {
|
@media (min-width: 1024px) {
|
||||||
.card--padded {
|
.card--padded {
|
||||||
padding: var(--space-5);
|
padding: var(--space-5);
|
||||||
@@ -773,6 +784,11 @@
|
|||||||
transform: scale(0.98);
|
transform: scale(0.98);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.btn:focus-visible {
|
||||||
|
outline: 2px solid var(--active-module-accent, var(--color-accent));
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
.btn--primary {
|
.btn--primary {
|
||||||
background-color: var(--color-btn-primary);
|
background-color: var(--color-btn-primary);
|
||||||
color: #ffffff;
|
color: #ffffff;
|
||||||
@@ -864,6 +880,11 @@
|
|||||||
transform: scale(0.95);
|
transform: scale(0.95);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.fab:focus-visible {
|
||||||
|
outline: 2px solid #fff;
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
@media (min-width: 1024px) {
|
@media (min-width: 1024px) {
|
||||||
.fab {
|
.fab {
|
||||||
bottom: var(--space-8);
|
bottom: var(--space-8);
|
||||||
@@ -961,6 +982,74 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* --------------------------------------------------------
|
||||||
|
* Toggle-Switch
|
||||||
|
* Custom iOS-style toggle, ersetzt native Checkboxen.
|
||||||
|
* Usage: <label class="toggle"><input type="checkbox"><span class="toggle__track"></span></label>
|
||||||
|
* -------------------------------------------------------- */
|
||||||
|
.toggle {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
cursor: pointer;
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle input {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
overflow: hidden;
|
||||||
|
clip: rect(0, 0, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle__track {
|
||||||
|
position: relative;
|
||||||
|
width: 44px;
|
||||||
|
height: 26px;
|
||||||
|
background-color: var(--neutral-300);
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
transition: background-color 0.2s ease;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle__track::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 3px;
|
||||||
|
left: 3px;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.15);
|
||||||
|
transition: transform 0.2s var(--ease-out);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle input:checked + .toggle__track {
|
||||||
|
background-color: var(--active-module-accent, var(--color-accent));
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle input:checked + .toggle__track::after {
|
||||||
|
transform: translateX(18px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle input:focus-visible + .toggle__track {
|
||||||
|
outline: 2px solid var(--active-module-accent, var(--color-accent));
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle input:disabled + .toggle__track {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.toggle__track,
|
||||||
|
.toggle__track::after {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* --------------------------------------------------------
|
/* --------------------------------------------------------
|
||||||
* Skeleton-Loading
|
* Skeleton-Loading
|
||||||
* -------------------------------------------------------- */
|
* -------------------------------------------------------- */
|
||||||
@@ -992,6 +1081,16 @@
|
|||||||
gap: var(--space-3);
|
gap: var(--space-3);
|
||||||
padding: var(--space-12) var(--space-6);
|
padding: var(--space-12) var(--space-6);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
animation: empty-state-in 0.4s var(--ease-out) both;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes empty-state-in {
|
||||||
|
from { opacity: 0; transform: translateY(6px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.empty-state { animation: none; }
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty-state__icon {
|
.empty-state__icon {
|
||||||
@@ -1212,6 +1311,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.toast {
|
.toast {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
background-color: var(--neutral-800);
|
background-color: var(--neutral-800);
|
||||||
color: var(--neutral-50);
|
color: var(--neutral-50);
|
||||||
padding: var(--space-3) var(--space-4);
|
padding: var(--space-3) var(--space-4);
|
||||||
@@ -1222,6 +1324,12 @@
|
|||||||
animation: toast-in 0.25s var(--ease-out) forwards;
|
animation: toast-in 0.25s var(--ease-out) forwards;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.toast__icon {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
.toast--success { background-color: var(--color-success); color: #fff; }
|
.toast--success { background-color: var(--color-success); color: #fff; }
|
||||||
.toast--danger { background-color: var(--color-danger); color: #fff; }
|
.toast--danger { background-color: var(--color-danger); color: #fff; }
|
||||||
.toast--warning { background-color: var(--color-warning); color: #fff; }
|
.toast--warning { background-color: var(--color-warning); color: #fff; }
|
||||||
|
|||||||
Reference in New Issue
Block a user