feat: microinteraction long loops and polish improvements
This commit is contained in:
@@ -608,6 +608,20 @@ function _validateField(input) {
|
|||||||
group?.classList.toggle('form-field--error', !hasValue);
|
group?.classList.toggle('form-field--error', !hasValue);
|
||||||
group?.classList.toggle('form-field--valid', hasValue);
|
group?.classList.toggle('form-field--valid', hasValue);
|
||||||
input.setAttribute('aria-invalid', String(!hasValue));
|
input.setAttribute('aria-invalid', String(!hasValue));
|
||||||
|
|
||||||
|
if (!hasValue && group) {
|
||||||
|
const count = parseInt(group.dataset.errorCount ?? '0', 10) + 1;
|
||||||
|
group.dataset.errorCount = String(count);
|
||||||
|
if (count >= 2) {
|
||||||
|
group.classList.remove('form-field--error-repeat');
|
||||||
|
void group.offsetWidth;
|
||||||
|
group.classList.add('form-field--error-repeat');
|
||||||
|
group.addEventListener('animationend', () => group.classList.remove('form-field--error-repeat'), { once: true });
|
||||||
|
}
|
||||||
|
} else if (hasValue && group) {
|
||||||
|
group.dataset.errorCount = '0';
|
||||||
|
}
|
||||||
|
|
||||||
return hasValue;
|
return hasValue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -352,6 +352,8 @@ function wireQuickAdd(container) {
|
|||||||
// Erfolgs-Feedback auf dem +-Button (DOM-API, kein innerHTML)
|
// Erfolgs-Feedback auf dem +-Button (DOM-API, kein innerHTML)
|
||||||
_flashAddBtn(form.querySelector('.quick-add__btn'));
|
_flashAddBtn(form.querySelector('.quick-add__btn'));
|
||||||
nameInput.focus();
|
nameInput.focus();
|
||||||
|
nameInput.classList.add('quick-add__input--flash');
|
||||||
|
nameInput.addEventListener('animationend', () => nameInput.classList.remove('quick-add__input--flash'), { once: true });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
window.oikos.showToast(err.message, 'danger');
|
window.oikos.showToast(err.message, 'danger');
|
||||||
}
|
}
|
||||||
|
|||||||
+34
-1
@@ -366,6 +366,16 @@ async function renderPage(route, previousPath = null) {
|
|||||||
|
|
||||||
await module.render(pageWrapper, { user: currentUser });
|
await module.render(pageWrapper, { user: currentUser });
|
||||||
|
|
||||||
|
// FAB Long Loop: Einstiegsanimation nach FAB_SEEN_MAX Views deaktivieren
|
||||||
|
if (pageWrapper.querySelector('.page-fab')) {
|
||||||
|
let fabCount = parseInt(localStorage.getItem(FAB_SEEN_KEY) ?? '0', 10);
|
||||||
|
if (fabCount < FAB_SEEN_MAX) {
|
||||||
|
fabCount++;
|
||||||
|
localStorage.setItem(FAB_SEEN_KEY, String(fabCount));
|
||||||
|
}
|
||||||
|
document.documentElement.classList.toggle('fab-anim-done', fabCount >= FAB_SEEN_MAX);
|
||||||
|
}
|
||||||
|
|
||||||
// Route-Announcer: Screenreader über Seitenwechsel informieren (gezielt, nicht gesamter Inhalt)
|
// Route-Announcer: Screenreader über Seitenwechsel informieren (gezielt, nicht gesamter Inhalt)
|
||||||
const announcer = document.getElementById('route-announcer');
|
const announcer = document.getElementById('route-announcer');
|
||||||
if (announcer) {
|
if (announcer) {
|
||||||
@@ -617,10 +627,23 @@ function renderAppShell(container) {
|
|||||||
initNavHideOnScroll(container);
|
initNavHideOnScroll(container);
|
||||||
initOfflineBanner();
|
initOfflineBanner();
|
||||||
initKeyboardShortcuts();
|
initKeyboardShortcuts();
|
||||||
|
if (localStorage.getItem(SEARCH_KBD_KEY)) {
|
||||||
|
document.documentElement.classList.add('search-kbd-done');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const FAB_SEEN_KEY = 'oikos:fabSeenCount';
|
||||||
|
const FAB_SEEN_MAX = 5;
|
||||||
|
const SEARCH_KBD_KEY = 'oikos:searchKbdUsed';
|
||||||
|
|
||||||
const SHORTCUTS = [
|
const SHORTCUTS = [
|
||||||
{ key: '/', description: () => t('shortcuts.search'), action: () => document.getElementById('more-sheet-search')?.click() },
|
{ key: '/', description: () => t('shortcuts.search'), action: () => {
|
||||||
|
if (!localStorage.getItem(SEARCH_KBD_KEY)) {
|
||||||
|
localStorage.setItem(SEARCH_KBD_KEY, '1');
|
||||||
|
document.documentElement.classList.add('search-kbd-done');
|
||||||
|
}
|
||||||
|
document.getElementById('more-sheet-search')?.click();
|
||||||
|
} },
|
||||||
{ key: 'n', description: () => t('shortcuts.new'), action: () => document.querySelector('.page-fab')?.click() },
|
{ key: 'n', description: () => t('shortcuts.new'), action: () => document.querySelector('.page-fab')?.click() },
|
||||||
{ key: '?', description: () => t('shortcuts.help'), action: () => showShortcutsModal() },
|
{ key: '?', description: () => t('shortcuts.help'), action: () => showShortcutsModal() },
|
||||||
{ key: 'g d', description: () => t('shortcuts.goDash'), action: () => navigate('/') },
|
{ key: 'g d', description: () => t('shortcuts.goDash'), action: () => navigate('/') },
|
||||||
@@ -1107,6 +1130,9 @@ 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_SUCCESS_KEY = 'oikos:toastSuccessCount';
|
||||||
|
const TOAST_SUCCESS_MAX = 50;
|
||||||
|
|
||||||
const TOAST_ICONS = {
|
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>',
|
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>',
|
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>',
|
||||||
@@ -1117,6 +1143,13 @@ function showToast(message, type = 'default', duration = 3000, onUndo = null) {
|
|||||||
const container = document.getElementById('toast-container');
|
const container = document.getElementById('toast-container');
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
|
|
||||||
|
// Long Loop: Success-Toasts nach TOAST_SUCCESS_MAX Aufrufen unterdrücken
|
||||||
|
if (type === 'success' && typeof onUndo !== 'function') {
|
||||||
|
const successCount = parseInt(localStorage.getItem(TOAST_SUCCESS_KEY) ?? '0', 10) + 1;
|
||||||
|
localStorage.setItem(TOAST_SUCCESS_KEY, String(successCount));
|
||||||
|
if (successCount > TOAST_SUCCESS_MAX) return;
|
||||||
|
}
|
||||||
|
|
||||||
// Max. 3 gleichzeitige Toasts: ältesten entfernen falls Limit erreicht
|
// Max. 3 gleichzeitige Toasts: ältesten entfernen falls Limit erreicht
|
||||||
const existing = container.querySelectorAll('.toast');
|
const existing = container.querySelectorAll('.toast');
|
||||||
if (existing.length >= 3) existing[0].remove();
|
if (existing.length >= 3) existing[0].remove();
|
||||||
|
|||||||
@@ -305,7 +305,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 1024px) {
|
@media (min-width: 1024px) {
|
||||||
.more-sheet__search-kbd {
|
html:not(.search-kbd-done) .more-sheet__search-kbd {
|
||||||
display: inline;
|
display: inline;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -579,6 +579,11 @@
|
|||||||
box-shadow: 0 0 0 4px var(--color-accent);
|
box-shadow: 0 0 0 4px var(--color-accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Long Loop: FAB-Animation nach N Aufrufen deaktivieren */
|
||||||
|
html.fab-anim-done .page-fab {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
|
||||||
/* 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 {
|
||||||
@@ -1383,6 +1388,22 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Pulse-Animation bei wiederholtem Fehler */
|
||||||
|
@keyframes field-error-pulse {
|
||||||
|
0%, 100% { box-shadow: none; }
|
||||||
|
40% { box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-danger) 35%, transparent); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-field--error-repeat .input,
|
||||||
|
.form-field--error-repeat .form-input {
|
||||||
|
animation: field-error-pulse 500ms var(--ease-out);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.form-field--error-repeat .input,
|
||||||
|
.form-field--error-repeat .form-input { animation: none; }
|
||||||
|
}
|
||||||
|
|
||||||
/* --------------------------------------------------------
|
/* --------------------------------------------------------
|
||||||
* Toggle-Switch
|
* Toggle-Switch
|
||||||
* Custom iOS-style toggle, ersetzt native Checkboxen.
|
* Custom iOS-style toggle, ersetzt native Checkboxen.
|
||||||
@@ -1505,6 +1526,7 @@
|
|||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
.empty-state { animation: none; }
|
.empty-state { animation: none; }
|
||||||
|
.empty-state__cta { animation: none; }
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty-state__icon {
|
.empty-state__icon {
|
||||||
@@ -1540,6 +1562,7 @@
|
|||||||
|
|
||||||
.empty-state__cta {
|
.empty-state__cta {
|
||||||
margin-top: var(--space-2);
|
margin-top: var(--space-2);
|
||||||
|
animation: list-item-in 300ms var(--ease-out) 300ms both;
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty-state--compact {
|
.empty-state--compact {
|
||||||
@@ -2313,6 +2336,23 @@ textarea.input { resize: vertical; }
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* --------------------------------------------------------
|
||||||
|
* Signature Moment: Eingabefeld-Glow nach erfolgreichem Item-Add
|
||||||
|
* -------------------------------------------------------- */
|
||||||
|
@keyframes input-add-flash {
|
||||||
|
0% { box-shadow: none; }
|
||||||
|
25% { box-shadow: 0 0 0 3px color-mix(in srgb, var(--module-accent, var(--color-accent)) 45%, transparent); }
|
||||||
|
100% { box-shadow: none; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-add__input--flash {
|
||||||
|
animation: input-add-flash 550ms var(--ease-out) forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.quick-add__input--flash { animation: none; }
|
||||||
|
}
|
||||||
|
|
||||||
/* --------------------------------------------------------
|
/* --------------------------------------------------------
|
||||||
* Windows High Contrast / Forced Colors
|
* Windows High Contrast / Forced Colors
|
||||||
* -------------------------------------------------------- */
|
* -------------------------------------------------------- */
|
||||||
|
|||||||
@@ -39,12 +39,20 @@ const _origSetTimeout = setTimeout;
|
|||||||
|
|
||||||
function makeField() {
|
function makeField() {
|
||||||
const classes = new Set();
|
const classes = new Set();
|
||||||
|
const listeners = {};
|
||||||
|
const dataset = {};
|
||||||
return {
|
return {
|
||||||
|
dataset,
|
||||||
|
offsetWidth: 0,
|
||||||
classList: {
|
classList: {
|
||||||
toggle(cls, force) { force ? classes.add(cls) : classes.delete(cls); },
|
toggle(cls, force) { force ? classes.add(cls) : classes.delete(cls); },
|
||||||
|
add(cls) { classes.add(cls); },
|
||||||
|
remove(cls) { classes.delete(cls); },
|
||||||
contains(cls) { return classes.has(cls); },
|
contains(cls) { return classes.has(cls); },
|
||||||
},
|
},
|
||||||
|
addEventListener(event, fn) { listeners[event] = fn; },
|
||||||
_classes: classes,
|
_classes: classes,
|
||||||
|
_listeners: listeners,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user