feat: add stagger() and vibrate() UX utilities with tests
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+2
-1
@@ -14,7 +14,8 @@
|
|||||||
"test:meals": "node --experimental-sqlite test-meals.js",
|
"test:meals": "node --experimental-sqlite test-meals.js",
|
||||||
"test:calendar": "node --experimental-sqlite test-calendar.js",
|
"test:calendar": "node --experimental-sqlite test-calendar.js",
|
||||||
"test:ncb": "node --experimental-sqlite test-notes-contacts-budget.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": {
|
"dependencies": {
|
||||||
"bcrypt": "^5.1.1",
|
"bcrypt": "^5.1.1",
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user