15 KiB
Shopping Swipe Gestures Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Add left-swipe-to-toggle and right-swipe-to-delete touch gestures to Shopping list items on mobile, replacing the visible × delete button on small screens.
Architecture: Shared swipe base CSS is moved from tasks.css to layout.css so both modules can use it. Shopping-specific styles (delete reveal, mobile button hiding) go in shopping.css. shopping.js wraps each item in a swipe-row and registers touch handlers via a new wireSwipeGestures() function, called after every list re-render.
Tech Stack: Vanilla JS (ES modules), Touch Events API, CSS custom properties, Node.js built-in test runner (regression tests only — no DOM test framework in this project).
File Map
| File | Change |
|---|---|
public/styles/tasks.css |
Remove .swipe-row, .swipe-reveal, .swipe-reveal--done (moved to layout.css) |
public/styles/layout.css |
Add moved shared swipe styles + .swipe-reveal--done |
public/styles/shopping.css |
Add .swipe-reveal--delete, .swipe-row .shopping-item, .swipe-row--swiping .shopping-item, mobile hide for .item-delete |
public/pages/shopping.js |
Wrap renderItem() output in swipe-row, add wireSwipeGestures(container), call it from updateItemsList() |
Task 1: Move shared swipe CSS from tasks.css to layout.css
Files:
-
Modify:
public/styles/tasks.css(lines 170–229) -
Modify:
public/styles/layout.css(append before Print section) -
Step 1: Remove base swipe styles from tasks.css
In public/styles/tasks.css, delete the block from /* Swipe-Wrapper (Mobil-Gesten) */ through .swipe-reveal--done { ... } (lines 170–216). Keep only the task-specific rules that follow:
/* Kein Margin mehr am Task-Card selbst (übernimmt swipe-row) */
.swipe-row .task-card {
margin-bottom: 0;
border-radius: var(--radius-md);
position: relative;
z-index: 1;
will-change: transform;
}
/* Rechts hinter der Karte = Bearbeiten (Swipe nach rechts) */
.swipe-reveal--edit {
left: 0;
background-color: var(--color-accent);
color: #fff;
border-radius: var(--radius-md) 0 0 var(--radius-md);
}
/* Touch-Feedback: leichte Hervorhebung während Swipe */
.swipe-row--swiping .task-card {
box-shadow: var(--shadow-lg);
}
Add a comment header so the section remains clear:
/* --------------------------------------------------------
* Swipe-Wrapper — Task-spezifische Styles
* Basis-Styles (.swipe-row, .swipe-reveal, .swipe-reveal--done)
* liegen in layout.css
* -------------------------------------------------------- */
- Step 2: Add shared swipe styles to layout.css
In public/styles/layout.css, find the Print section (/* Print-Styles */) and insert the following block directly before it:
/* --------------------------------------------------------
* Swipe-Wrapper — Gemeinsame Basis (Tasks + Shopping)
* Modul-spezifische Styles (.swipe-reveal--edit, .swipe-reveal--delete,
* .swipe-row .task-card, .swipe-row .shopping-item) liegen in den Modul-CSS.
* -------------------------------------------------------- */
.swipe-row {
position: relative;
overflow: hidden;
border-radius: var(--radius-md);
margin-bottom: var(--space-2);
/* Verhindert ungewolltes Flackern auf iOS */
-webkit-backface-visibility: hidden;
}
/* Reveal-Panels hinter der Karte */
.swipe-reveal {
position: absolute;
top: 0;
bottom: 0;
width: 50%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--space-1);
font-size: var(--text-xs);
font-weight: var(--font-weight-semibold);
opacity: 0;
pointer-events: none;
z-index: 0;
transition: opacity 0.05s linear;
}
/* Gemeinsam: Erledigt / Abhaken (Swipe nach links) */
.swipe-reveal--done {
right: 0;
background-color: var(--color-success);
color: #fff;
border-radius: 0 var(--radius-md) var(--radius-md) 0;
}
- Step 3: Run regression tests
npm test
Expected: # pass 12 and # fail 0
- Step 4: Verify tasks swipe still works visually
Open the app in a browser (or mobile DevTools touch emulation), go to Tasks list, swipe an item left and right. The green "Erledigt" and blue "Bearbeiten" panels must still appear correctly.
- Step 5: Commit
git add public/styles/tasks.css public/styles/layout.css
git commit -m "refactor: move shared swipe CSS from tasks.css to layout.css"
Task 2: Add shopping-specific swipe CSS
Files:
-
Modify:
public/styles/shopping.css -
Step 1: Find the correct insertion point in shopping.css
In public/styles/shopping.css, locate the section for .shopping-item styles. The new swipe styles go after the existing .shopping-item block.
- Step 2: Add the styles
Append the following after the existing .shopping-item rules in public/styles/shopping.css:
/* --------------------------------------------------------
* Swipe-Wrapper — Shopping-spezifische Styles
* -------------------------------------------------------- */
/* Kein Margin mehr am shopping-item selbst (übernimmt swipe-row) */
.swipe-row .shopping-item {
margin-bottom: 0;
border-radius: var(--radius-md);
position: relative;
z-index: 1;
will-change: transform;
}
/* Rechts hinter der Karte = Löschen (Swipe nach rechts) */
.swipe-reveal--delete {
left: 0;
background-color: var(--color-danger);
color: #fff;
border-radius: var(--radius-md) 0 0 var(--radius-md);
}
/* Touch-Feedback: leichte Hervorhebung während Swipe */
.swipe-row--swiping .shopping-item {
box-shadow: var(--shadow-lg);
}
/* × Löschen-Button auf Mobile ausblenden — Swipe übernimmt */
@media (max-width: 1023px) {
.item-delete {
display: none;
}
}
- Step 3: Run regression tests
npm test
Expected: # pass 12 and # fail 0
- Step 4: Commit
git add public/styles/shopping.css
git commit -m "feat: add shopping swipe CSS (delete reveal, mobile button hide)"
Task 3: Wrap renderItem with swipe-row in shopping.js
Files:
-
Modify:
public/pages/shopping.js -
Step 1: Update renderItem()
Find function renderItem(item) in public/pages/shopping.js (currently returns a .shopping-item div). Replace the entire function with:
function renderItem(item) {
const isDone = Boolean(item.is_checked);
return `
<div class="swipe-row" data-swipe-id="${item.id}" data-swipe-checked="${item.is_checked}">
<div class="swipe-reveal swipe-reveal--done" aria-hidden="true">
<i data-lucide="${isDone ? 'rotate-ccw' : 'check'}" style="width:22px;height:22px" aria-hidden="true"></i>
<span>${isDone ? 'Zurück' : 'Abhaken'}</span>
</div>
<div class="swipe-reveal swipe-reveal--delete" aria-hidden="true">
<i data-lucide="trash-2" style="width:22px;height:22px" aria-hidden="true"></i>
<span>Löschen</span>
</div>
<div class="shopping-item ${isDone ? 'shopping-item--checked' : ''}"
data-item-id="${item.id}">
<button class="item-check ${isDone ? 'item-check--checked' : ''}"
data-action="toggle-item" data-id="${item.id}" data-checked="${item.is_checked}"
aria-label="${item.name} ${isDone ? 'als nicht erledigt markieren' : 'abhaken'}">
<i data-lucide="check" class="item-check__icon" aria-hidden="true"></i>
</button>
<div class="item-body">
<div class="item-name">${item.name}</div>
${item.quantity ? `<div class="item-quantity">${item.quantity}</div>` : ''}
</div>
<button class="item-delete" data-action="delete-item" data-id="${item.id}"
aria-label="${item.name} löschen">
<i data-lucide="x" style="width:16px;height:16px" aria-hidden="true"></i>
</button>
</div>
</div>`;
}
- Step 2: Run regression tests
npm test
Expected: # pass 12 and # fail 0
- Step 3: Verify shopping list renders without errors
Open the app in a browser, navigate to Shopping. Items must render with the swipe-row wrapper. The × button must be hidden on mobile (visible on desktop ≥ 1024px). The existing checkbox toggle must still work.
- Step 4: Commit
git add public/pages/shopping.js
git commit -m "feat: wrap shopping items in swipe-row"
Task 4: Add wireSwipeGestures and wire into updateItemsList
Files:
-
Modify:
public/pages/shopping.js -
Step 1: Add constants at the top of shopping.js
After the import statements (before the state declaration), add:
// Swipe-Gesten Konstanten (identisch zu tasks.js)
const SWIPE_THRESHOLD = 80; // px — Mindestweg für Aktion
const SWIPE_MAX_VERT = 12; // px — vertikaler Toleranzbereich
const SWIPE_LOCK_VERT = 30; // px — ab diesem Weg gilt es als Scroll
- Step 2: Add wireSwipeGestures() function
Add the following function before updateItemsList() in shopping.js:
function wireSwipeGestures(container) {
const listEl = container.querySelector('#items-list');
if (!listEl) return;
listEl.querySelectorAll('.swipe-row').forEach((row) => {
let startX = 0, startY = 0;
let dx = 0;
let locked = false; // false | 'swipe' | 'scroll'
const card = row.querySelector('.shopping-item');
if (!card) return;
function resetCard(animate = true) {
card.style.transition = animate ? 'transform 0.25s ease' : '';
card.style.transform = '';
row.classList.remove('swipe-row--swiping');
row.querySelector('.swipe-reveal--done').style.opacity = '0';
row.querySelector('.swipe-reveal--delete').style.opacity = '0';
}
row.addEventListener('touchstart', (e) => {
if (document.getElementById('shared-modal-overlay')) return;
startX = e.touches[0].clientX;
startY = e.touches[0].clientY;
dx = 0;
locked = false;
card.style.transition = '';
}, { passive: true });
row.addEventListener('touchmove', (e) => {
if (locked === 'scroll') return;
const currentX = e.touches[0].clientX;
const currentY = e.touches[0].clientY;
dx = currentX - startX;
const dy = Math.abs(currentY - startY);
if (locked === false) {
if (dy > SWIPE_MAX_VERT && Math.abs(dx) < dy) {
locked = 'scroll';
resetCard(false);
return;
}
if (Math.abs(dx) > SWIPE_MAX_VERT) {
locked = 'swipe';
}
}
if (locked !== 'swipe') return;
if (dy < SWIPE_LOCK_VERT) e.preventDefault();
const dampened = dx > 0
? Math.min(dx, SWIPE_THRESHOLD + (dx - SWIPE_THRESHOLD) * 0.2)
: Math.max(dx, -(SWIPE_THRESHOLD + (-dx - SWIPE_THRESHOLD) * 0.2));
card.style.transform = `translateX(${dampened}px)`;
row.classList.add('swipe-row--swiping');
const progress = Math.min(Math.abs(dx) / SWIPE_THRESHOLD, 1);
if (dx < 0) {
row.querySelector('.swipe-reveal--done').style.opacity = String(progress);
row.querySelector('.swipe-reveal--delete').style.opacity = '0';
} else {
row.querySelector('.swipe-reveal--delete').style.opacity = String(progress);
row.querySelector('.swipe-reveal--done').style.opacity = '0';
}
}, { passive: false });
row.addEventListener('touchend', async () => {
if (locked !== 'swipe') { resetCard(false); return; }
const itemId = Number(row.dataset.swipeId);
const checked = Number(row.dataset.swipeChecked);
if (dx < -SWIPE_THRESHOLD) {
// Swipe links → abhaken / zurück
card.style.transition = 'transform 0.2s ease';
card.style.transform = 'translateX(-110%)';
vibrate(40);
setTimeout(async () => {
resetCard(false);
const newVal = checked ? 0 : 1;
const item = state.items.find((i) => i.id === itemId);
if (item) {
item.is_checked = newVal;
updateItemsList(container);
updateListCounter(state.activeListId, 0, newVal ? 1 : -1);
renderTabs(container);
}
try {
await api.patch(`/shopping/items/${itemId}`, { is_checked: newVal });
vibrate(10);
} catch (err) {
if (item) item.is_checked = checked;
updateItemsList(container);
window.oikos.showToast(err.message, 'danger');
}
}, 200);
} else if (dx > SWIPE_THRESHOLD) {
// Swipe rechts → löschen
card.style.transition = 'transform 0.2s ease';
card.style.transform = 'translateX(110%)';
vibrate(40);
setTimeout(async () => {
const item = state.items.find((i) => i.id === itemId);
try {
await api.delete(`/shopping/items/${itemId}`);
state.items = state.items.filter((i) => i.id !== itemId);
updateItemsList(container);
updateListCounter(state.activeListId, -1, item?.is_checked ? -1 : 0);
renderTabs(container);
} catch (err) {
resetCard(true);
window.oikos.showToast(err.message, 'danger');
}
}, 200);
} else {
resetCard(true);
}
});
});
}
- Step 3: Call wireSwipeGestures from updateItemsList()
In updateItemsList(container), after the stagger(...) call, add:
wireSwipeGestures(container);
The updated block looks like this:
function updateItemsList(container) {
const listEl = container.querySelector('#items-list');
if (listEl) {
listEl.innerHTML = renderItems();
if (window.lucide) window.lucide.createIcons();
stagger(listEl.querySelectorAll('.shopping-item'));
wireSwipeGestures(container); // ← new
}
// ... rest of function unchanged
}
- Step 4: Run regression tests
npm test
Expected: # pass 12 and # fail 0
- Step 5: Verify swipe gestures work end-to-end
Test in mobile DevTools (Chrome → Toggle device toolbar → touch emulation):
- Open Shopping, add 3 test items
- Swipe an item left past 80 px → green panel appears → item toggles to checked (strikethrough) → swipe again to uncheck
- Swipe an item right past 80 px → red panel appears → item is removed from list
- Swipe < 80 px in either direction → card springs back, no action
- Scroll the list vertically → no swipe triggers
- On desktop (≥ 1024px): × delete button is visible, swipe not triggered by mouse
- Step 6: Update CHANGELOG.md
Add to ## [Unreleased] → ### Added:
- Shopping: swipe-left to toggle checked/unchecked, swipe-right to delete items on mobile; × delete button hidden on mobile in favour of swipe gesture
- Step 7: Commit
git add public/pages/shopping.js CHANGELOG.md
git commit -m "feat: swipe gestures on shopping list items (toggle + delete)"
Task 5: Push
- Push to remote
git push