From f4eb567219ef7df1e51e17138ec6dbd9aafad5c9 Mon Sep 17 00:00:00 2001 From: Ulas Date: Mon, 30 Mar 2026 17:14:15 +0200 Subject: [PATCH] feat: add stagger() and vibrate() UX utilities with tests Co-Authored-By: Claude Sonnet 4.6 --- package.json | 3 ++- public/utils/ux.js | 42 +++++++++++++++++++++++++++++++++++++++ test-ux-utils.js | 49 ++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 93 insertions(+), 1 deletion(-) create mode 100644 public/utils/ux.js create mode 100644 test-ux-utils.js diff --git a/package.json b/package.json index 6a47635..8637035 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,8 @@ "test:meals": "node --experimental-sqlite test-meals.js", "test:calendar": "node --experimental-sqlite test-calendar.js", "test:ncb": "node --experimental-sqlite test-notes-contacts-budget.js", - "test": "node --experimental-sqlite test-db.js && node --experimental-sqlite test-dashboard.js && node --experimental-sqlite test-tasks.js && node --experimental-sqlite test-shopping.js && node --experimental-sqlite test-meals.js && node --experimental-sqlite test-calendar.js && node --experimental-sqlite test-notes-contacts-budget.js" + "test:ux-utils": "node --experimental-vm-modules test-ux-utils.js", + "test": "node --experimental-sqlite test-db.js && node --experimental-sqlite test-dashboard.js && node --experimental-sqlite test-tasks.js && node --experimental-sqlite test-shopping.js && node --experimental-sqlite test-meals.js && node --experimental-sqlite test-calendar.js && node --experimental-sqlite test-notes-contacts-budget.js && npm run test:ux-utils" }, "dependencies": { "bcrypt": "^5.1.1", diff --git a/public/utils/ux.js b/public/utils/ux.js new file mode 100644 index 0000000..bd0c704 --- /dev/null +++ b/public/utils/ux.js @@ -0,0 +1,42 @@ +/** + * Modul: UX Utilities + * Zweck: Wiederverwendbare Animationshelfer (Stagger, Vibration) + * Abhängigkeiten: keine + */ + +/** + * Gestaffeltes Einblenden einer NodeList oder eines Arrays von Elementen. + * Maximal MAX_STAGGER Elemente werden verzögert, der Rest sofort eingeblendet. + * + * @param {NodeList|Element[]} elements + * @param {Object} [opts] + * @param {number} [opts.delay=30] — ms zwischen jedem Element + * @param {number} [opts.duration=180] — ms pro Element + * @param {number} [opts.max=5] — Maximale Anzahl gestaffelter Elemente + */ +export function stagger(elements, { delay = 30, duration = 180, max = 5 } = {}) { + if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return; + const els = Array.from(elements); + els.forEach((el, i) => { + const itemDelay = i < max ? i * delay : max * delay; + el.style.opacity = '0'; + el.style.transform = 'translateY(8px)'; + el.style.transition = `opacity ${duration}ms ease, transform ${duration}ms ease`; + setTimeout(() => { + el.style.opacity = '1'; + el.style.transform = 'translateY(0)'; + }, itemDelay); + }); +} + +/** + * Vibrationsmuster abspielen, wenn die API verfügbar ist und + * keine reduzierte Bewegung gewünscht wird. + * + * @param {number|number[]} pattern — ms oder [an, aus, an, ...]-Array + */ +export function vibrate(pattern) { + if (!navigator.vibrate) return; + if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return; + navigator.vibrate(pattern); +} diff --git a/test-ux-utils.js b/test-ux-utils.js new file mode 100644 index 0000000..00cc991 --- /dev/null +++ b/test-ux-utils.js @@ -0,0 +1,49 @@ +/** + * Tests: UX Utilities (stagger, vibrate) + * Läuft im Node-Kontext — kein DOM verfügbar, daher nur Pure-Logic-Tests. + */ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +// Minimales Window/Navigator-Mock für Node +const { stagger, vibrate } = await (async () => { + // stagger braucht window.matchMedia — wir mocken es + global.window = { + matchMedia: () => ({ matches: false }), + }; + // navigator ist in Node ein getter-only property — über defineProperty überschreiben + Object.defineProperty(global, 'navigator', { + value: { vibrate: null }, + writable: true, + configurable: true, + }); + return import('./public/utils/ux.js'); +})(); + +test('stagger: setzt opacity:0 auf alle Elemente', () => { + const els = [{ style: {} }, { style: {} }, { style: {} }]; + stagger(els, { delay: 0, duration: 0 }); + assert.equal(els[0].style.opacity, '0'); + assert.equal(els[1].style.opacity, '0'); + assert.equal(els[2].style.opacity, '0'); +}); + +test('stagger: tut nichts bei prefers-reduced-motion', () => { + global.window.matchMedia = () => ({ matches: true }); + const els = [{ style: {} }]; + stagger(els); + assert.equal(els[0].style.opacity, undefined); // unverändert + global.window.matchMedia = () => ({ matches: false }); // reset +}); + +test('vibrate: tut nichts wenn API nicht vorhanden', () => { + Object.defineProperty(global, 'navigator', { value: { vibrate: null }, writable: true, configurable: true }); + assert.doesNotThrow(() => vibrate(10)); +}); + +test('vibrate: ruft navigator.vibrate auf wenn vorhanden', () => { + let called = null; + Object.defineProperty(global, 'navigator', { value: { vibrate: (p) => { called = p; } }, writable: true, configurable: true }); + vibrate(15); + assert.equal(called, 15); +});