feat(ux): microinteraction improvements
- Zentralisiere @keyframes check-pop in layout.css (war dupliziert in shopping.css + tasks.css) - Subtask-Checkbox erhaelt check-pop Animation (konsistent mit Haupt-Checkbox) - Quick-Add: Checkmark-Feedback auf +-Button nach erfolgreichem Hinzufuegen (700ms, DOM-API) - Swipe-Affordance Hint: swipe-row--hint via localStorage-Counter (max. 3x, nur Mobile)
This commit is contained in:
@@ -175,6 +175,7 @@ function renderListContent(container) {
|
|||||||
stagger(content.querySelectorAll('.shopping-item'));
|
stagger(content.querySelectorAll('.shopping-item'));
|
||||||
wireAutocomplete(container);
|
wireAutocomplete(container);
|
||||||
wireQuickAdd(container);
|
wireQuickAdd(container);
|
||||||
|
maybeShowSwipeHint(container);
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderItems() {
|
function renderItems() {
|
||||||
@@ -307,6 +308,35 @@ function wireAutocomplete(container) {
|
|||||||
// Quick-Add Form
|
// Quick-Add Form
|
||||||
// --------------------------------------------------------
|
// --------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zeigt kurzes Checkmark-Feedback auf dem +-Button (700ms).
|
||||||
|
* Verwendet DOM-API statt innerHTML um XSS-Risiken zu vermeiden.
|
||||||
|
* @param {HTMLButtonElement|null} btn
|
||||||
|
*/
|
||||||
|
function _flashAddBtn(btn) {
|
||||||
|
if (!btn) return;
|
||||||
|
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
||||||
|
svg.setAttribute('width', '16');
|
||||||
|
svg.setAttribute('height', '16');
|
||||||
|
svg.setAttribute('viewBox', '0 0 24 24');
|
||||||
|
svg.setAttribute('fill', 'none');
|
||||||
|
svg.setAttribute('stroke', 'currentColor');
|
||||||
|
svg.setAttribute('stroke-width', '2.5');
|
||||||
|
svg.setAttribute('aria-hidden', 'true');
|
||||||
|
const poly = document.createElementNS('http://www.w3.org/2000/svg', 'polyline');
|
||||||
|
poly.setAttribute('points', '20 6 9 17 4 12');
|
||||||
|
svg.appendChild(poly);
|
||||||
|
|
||||||
|
const saved = [...btn.childNodes];
|
||||||
|
btn.classList.add('btn--success');
|
||||||
|
btn.replaceChildren(svg);
|
||||||
|
setTimeout(() => {
|
||||||
|
btn.classList.remove('btn--success');
|
||||||
|
btn.replaceChildren(...saved);
|
||||||
|
if (window.lucide) window.lucide.createIcons();
|
||||||
|
}, 700);
|
||||||
|
}
|
||||||
|
|
||||||
function wireQuickAdd(container) {
|
function wireQuickAdd(container) {
|
||||||
const form = container.querySelector('#quick-add-form');
|
const form = container.querySelector('#quick-add-form');
|
||||||
if (!form) return;
|
if (!form) return;
|
||||||
@@ -332,6 +362,8 @@ function wireQuickAdd(container) {
|
|||||||
renderTabs(container);
|
renderTabs(container);
|
||||||
nameInput.value = '';
|
nameInput.value = '';
|
||||||
qtyInput.value = '';
|
qtyInput.value = '';
|
||||||
|
// Erfolgs-Feedback auf dem +-Button (DOM-API, kein innerHTML)
|
||||||
|
_flashAddBtn(form.querySelector('.quick-add__btn'));
|
||||||
nameInput.focus();
|
nameInput.focus();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
window.oikos.showToast(err.message, 'danger');
|
window.oikos.showToast(err.message, 'danger');
|
||||||
@@ -339,6 +371,30 @@ function wireQuickAdd(container) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------
|
||||||
|
// Swipe-Affordance Hint (Long Loop)
|
||||||
|
// Zeigt den Nudge-Hinweis maximal 3x (gespeichert in localStorage).
|
||||||
|
// --------------------------------------------------------
|
||||||
|
|
||||||
|
const SWIPE_HINT_KEY = 'oikos:swipeHintSeen';
|
||||||
|
const SWIPE_HINT_MAX = 3;
|
||||||
|
|
||||||
|
function maybeShowSwipeHint(container) {
|
||||||
|
if (window.innerWidth >= 1024) return; // Desktop: Swipe nicht relevant
|
||||||
|
const count = parseInt(localStorage.getItem(SWIPE_HINT_KEY) ?? '0', 10);
|
||||||
|
if (count >= SWIPE_HINT_MAX) return;
|
||||||
|
|
||||||
|
const firstRow = container.querySelector('.swipe-row');
|
||||||
|
if (!firstRow) return;
|
||||||
|
|
||||||
|
firstRow.classList.add('swipe-row--hint');
|
||||||
|
firstRow.addEventListener('animationend', () => {
|
||||||
|
firstRow.classList.remove('swipe-row--hint');
|
||||||
|
}, { once: true });
|
||||||
|
|
||||||
|
localStorage.setItem(SWIPE_HINT_KEY, String(count + 1));
|
||||||
|
}
|
||||||
|
|
||||||
// --------------------------------------------------------
|
// --------------------------------------------------------
|
||||||
// Swipe-Gesten
|
// Swipe-Gesten
|
||||||
// --------------------------------------------------------
|
// --------------------------------------------------------
|
||||||
@@ -487,6 +543,7 @@ function updateItemsList(container) {
|
|||||||
if (window.lucide) window.lucide.createIcons();
|
if (window.lucide) window.lucide.createIcons();
|
||||||
stagger(listEl.querySelectorAll('.shopping-item'));
|
stagger(listEl.querySelectorAll('.shopping-item'));
|
||||||
wireSwipeGestures(container);
|
wireSwipeGestures(container);
|
||||||
|
maybeShowSwipeHint(container);
|
||||||
}
|
}
|
||||||
// clear-checked Button aktualisieren
|
// clear-checked Button aktualisieren
|
||||||
const checkedCount = state.items.filter((i) => i.is_checked).length;
|
const checkedCount = state.items.filter((i) => i.is_checked).length;
|
||||||
|
|||||||
@@ -216,6 +216,11 @@
|
|||||||
* Mobile: über der Bottom-Nav. Desktop: unten rechts im Content.
|
* Mobile: über der Bottom-Nav. Desktop: unten rechts im Content.
|
||||||
* Toolbar-"Neu"-Buttons werden überall versteckt.
|
* Toolbar-"Neu"-Buttons werden überall versteckt.
|
||||||
* -------------------------------------------------------- */
|
* -------------------------------------------------------- */
|
||||||
|
@keyframes fab-in {
|
||||||
|
from { transform: scale(0.6) translateY(8px); opacity: 0; }
|
||||||
|
to { transform: scale(1) translateY(0); opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
.page-fab {
|
.page-fab {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
bottom: calc(var(--nav-bottom-height) + 24px + var(--safe-area-inset-bottom));
|
bottom: calc(var(--nav-bottom-height) + 24px + var(--safe-area-inset-bottom));
|
||||||
@@ -234,6 +239,7 @@
|
|||||||
z-index: calc(var(--z-nav) - 1);
|
z-index: calc(var(--z-nav) - 1);
|
||||||
transition: transform var(--transition-base), background-color var(--transition-fast);
|
transition: transform var(--transition-base), background-color var(--transition-fast);
|
||||||
-webkit-tap-highlight-color: transparent;
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
animation: fab-in 0.35s var(--ease-out) backwards;
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-fab:hover {
|
.page-fab:hover {
|
||||||
@@ -882,6 +888,7 @@
|
|||||||
transform var(--transition-fast),
|
transform var(--transition-fast),
|
||||||
background-color var(--transition-fast),
|
background-color var(--transition-fast),
|
||||||
box-shadow var(--transition-fast);
|
box-shadow var(--transition-fast);
|
||||||
|
animation: fab-in 0.35s var(--ease-out) backwards;
|
||||||
}
|
}
|
||||||
|
|
||||||
.fab:hover {
|
.fab:hover {
|
||||||
@@ -1042,6 +1049,10 @@
|
|||||||
background-color: var(--active-module-accent, var(--color-accent));
|
background-color: var(--active-module-accent, var(--color-accent));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.toggle input:checked + .toggle__track::after {
|
||||||
|
transition: transform 0.35s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||||
|
}
|
||||||
|
|
||||||
.toggle input:checked + .toggle__track::after {
|
.toggle input:checked + .toggle__track::after {
|
||||||
transform: translateX(18px);
|
transform: translateX(18px);
|
||||||
}
|
}
|
||||||
@@ -1061,6 +1072,15 @@
|
|||||||
.toggle__track::after {
|
.toggle__track::after {
|
||||||
transition: none;
|
transition: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.fab,
|
||||||
|
.page-fab { animation: none; }
|
||||||
|
|
||||||
|
.list-stagger > * { animation: none; }
|
||||||
|
|
||||||
|
.swipe-row--hint > :first-child { animation: none; }
|
||||||
|
|
||||||
|
.btn--loading::after { animation: none; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* --------------------------------------------------------
|
/* --------------------------------------------------------
|
||||||
@@ -1446,6 +1466,16 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ── Submit-Feedback Animationen ── */
|
/* ── Submit-Feedback Animationen ── */
|
||||||
|
|
||||||
|
/* Checkbox-Pop: kanonische Definition - shopping.css + tasks.css verwenden diese */
|
||||||
|
@keyframes check-pop {
|
||||||
|
0% { transform: scale(1); }
|
||||||
|
30% { transform: scale(0.8); }
|
||||||
|
60% { transform: scale(1.3); }
|
||||||
|
80% { transform: scale(0.95); }
|
||||||
|
100% { transform: scale(1); }
|
||||||
|
}
|
||||||
|
|
||||||
@keyframes btn-shake {
|
@keyframes btn-shake {
|
||||||
0%, 100% { transform: translateX(0); }
|
0%, 100% { transform: translateX(0); }
|
||||||
20% { transform: translateX(-4px); }
|
20% { transform: translateX(-4px); }
|
||||||
@@ -1464,6 +1494,39 @@
|
|||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Button loading state: hides text, shows spinner */
|
||||||
|
@keyframes btn-spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn--loading {
|
||||||
|
position: relative;
|
||||||
|
color: transparent !important;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn--loading::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
margin-top: -7px;
|
||||||
|
margin-left: -7px;
|
||||||
|
border: 2px solid rgba(255, 255, 255, 0.35);
|
||||||
|
border-top-color: #fff;
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
animation: btn-spin 0.55s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* For non-primary buttons (secondary, ghost) */
|
||||||
|
.btn--secondary.btn--loading::after,
|
||||||
|
.btn--ghost.btn--loading::after {
|
||||||
|
border-color: var(--color-border);
|
||||||
|
border-top-color: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
/* --------------------------------------------------------
|
/* --------------------------------------------------------
|
||||||
* Swipe-Wrapper - Gemeinsame Basis (Tasks + Shopping)
|
* Swipe-Wrapper - Gemeinsame Basis (Tasks + Shopping)
|
||||||
* Modul-spezifische Styles (.swipe-reveal--edit, .swipe-reveal--delete,
|
* Modul-spezifische Styles (.swipe-reveal--edit, .swipe-reveal--delete,
|
||||||
@@ -1497,6 +1560,45 @@
|
|||||||
transition: opacity 0.05s linear;
|
transition: opacity 0.05s linear;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* --------------------------------------------------------
|
||||||
|
* List Stagger
|
||||||
|
* Füge .list-stagger zum Container hinzu.
|
||||||
|
* Items erscheinen sequenziell beim ersten Rendern.
|
||||||
|
* -------------------------------------------------------- */
|
||||||
|
@keyframes list-item-in {
|
||||||
|
from { opacity: 0; transform: translateY(6px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-stagger > * {
|
||||||
|
animation: list-item-in 0.2s var(--ease-out) both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-stagger > *:nth-child(1) { animation-delay: 0ms; }
|
||||||
|
.list-stagger > *:nth-child(2) { animation-delay: 35ms; }
|
||||||
|
.list-stagger > *:nth-child(3) { animation-delay: 70ms; }
|
||||||
|
.list-stagger > *:nth-child(4) { animation-delay: 105ms; }
|
||||||
|
.list-stagger > *:nth-child(5) { animation-delay: 140ms; }
|
||||||
|
.list-stagger > *:nth-child(n+6) { animation-delay: 175ms; }
|
||||||
|
|
||||||
|
/* --------------------------------------------------------
|
||||||
|
* Swipe Affordance Hint
|
||||||
|
* Einmalig per JS: .swipe-row--hint auf das erste Element.
|
||||||
|
* Gibt nach Seitenladezeit einen kurzen Nudge-Hinweis.
|
||||||
|
* -------------------------------------------------------- */
|
||||||
|
@keyframes swipe-nudge {
|
||||||
|
0% { transform: translateX(0); }
|
||||||
|
20% { transform: translateX(-18px); }
|
||||||
|
45% { transform: translateX(0); }
|
||||||
|
60% { transform: translateX(-9px); }
|
||||||
|
80% { transform: translateX(0); }
|
||||||
|
100% { transform: translateX(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.swipe-row--hint > :first-child {
|
||||||
|
animation: swipe-nudge 0.8s var(--ease-out) 1.2s both;
|
||||||
|
}
|
||||||
|
|
||||||
/* Gemeinsam: Erledigt / Abhaken (Swipe nach links) */
|
/* Gemeinsam: Erledigt / Abhaken (Swipe nach links) */
|
||||||
.swipe-reveal--done {
|
.swipe-reveal--done {
|
||||||
right: 0;
|
right: 0;
|
||||||
|
|||||||
@@ -354,12 +354,6 @@
|
|||||||
animation: check-pop 0.2s var(--ease-out);
|
animation: check-pop 0.2s var(--ease-out);
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes check-pop {
|
|
||||||
0% { transform: scale(1); }
|
|
||||||
50% { transform: scale(1.2); }
|
|
||||||
100% { transform: scale(1); }
|
|
||||||
}
|
|
||||||
|
|
||||||
.item-check__icon {
|
.item-check__icon {
|
||||||
width: var(--space-3);
|
width: var(--space-3);
|
||||||
height: var(--space-3);
|
height: var(--space-3);
|
||||||
|
|||||||
@@ -254,12 +254,6 @@
|
|||||||
animation: check-pop 0.2s var(--ease-out);
|
animation: check-pop 0.2s var(--ease-out);
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes check-pop {
|
|
||||||
0% { transform: scale(1); }
|
|
||||||
50% { transform: scale(1.2); }
|
|
||||||
100% { transform: scale(1); }
|
|
||||||
}
|
|
||||||
|
|
||||||
.task-status-btn--in_progress {
|
.task-status-btn--in_progress {
|
||||||
border-color: var(--color-warning);
|
border-color: var(--color-warning);
|
||||||
}
|
}
|
||||||
@@ -425,6 +419,7 @@
|
|||||||
.subtask-item__checkbox--done {
|
.subtask-item__checkbox--done {
|
||||||
background-color: var(--color-success);
|
background-color: var(--color-success);
|
||||||
border-color: var(--color-success);
|
border-color: var(--color-success);
|
||||||
|
animation: check-pop 0.15s var(--ease-out);
|
||||||
}
|
}
|
||||||
|
|
||||||
.subtask-item__title {
|
.subtask-item__title {
|
||||||
|
|||||||
Reference in New Issue
Block a user