Files
home_hub/design_system/package-smartphone/examples/mobile-gestures.jsx
T
gilles 4518ed8311 chore(design): ajout du package design system smartphone
Contient les tokens, composants et exemples adaptés au mobile,
à utiliser comme référence lors du développement des vues smartphone.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-30 08:53:36 +02:00

287 lines
11 KiB
React

/* ============================================================
mobile-gestures.jsx
Détecteur de gestes nommés pour smartphone.
Chaque geste a un NOM SYSTÈME, et un composant de TEST.
============================================================ */
const { useState: uG, useRef: rG, useEffect: eG } = React;
/* ============================================================
useGesture — hook bas niveau qui détecte les gestes
Renvoie : { onTouchStart, onTouchMove, onTouchEnd } à attacher
au composant qui doit recevoir les gestes.
Callbacks supportés :
onTap tap simple (< 200ms, ne bouge pas)
onDoubleTap double-tap (deux tap rapides)
onLongPress long press (≥ 500ms sans bouger)
onSwipeLeft swipe vers la gauche
onSwipeRight swipe vers la droite
onSwipeUp swipe vers le haut
onSwipeDown swipe vers le bas
onPanStart début de glisser
onPan cours de glisser ({dx, dy})
onPanEnd fin de glisser
onPinch pincement ({scale, dx, dy})
============================================================ */
function useGesture(handlers = {}) {
const state = rG({
sx: 0, sy: 0, st: 0,
lx: 0, ly: 0, lt: 0,
moved: false, longPressTimer: null,
lastTap: 0, lastTapPos: null,
pinching: false, startDist: 0,
});
const reset = () => {
if (state.current.longPressTimer) clearTimeout(state.current.longPressTimer);
};
const onTouchStart = (e) => {
const t = e.touches[0];
state.current.sx = t.clientX;
state.current.sy = t.clientY;
state.current.lx = t.clientX;
state.current.ly = t.clientY;
state.current.st = Date.now();
state.current.lt = Date.now();
state.current.moved = false;
// Pinch detection
if (e.touches.length === 2) {
const dx = e.touches[1].clientX - t.clientX;
const dy = e.touches[1].clientY - t.clientY;
state.current.startDist = Math.hypot(dx, dy);
state.current.pinching = true;
return;
}
// Long press
if (handlers.onLongPress) {
state.current.longPressTimer = setTimeout(() => {
if (!state.current.moved) {
handlers.onLongPress({ x: t.clientX, y: t.clientY });
state.current.moved = true; // empêche d'autres détections
}
}, 500);
}
handlers.onPanStart && handlers.onPanStart({ x: t.clientX, y: t.clientY });
};
const onTouchMove = (e) => {
const t = e.touches[0];
const dx = t.clientX - state.current.sx;
const dy = t.clientY - state.current.sy;
if (Math.abs(dx) > 10 || Math.abs(dy) > 10) {
state.current.moved = true;
reset();
}
if (state.current.pinching && e.touches.length === 2) {
const px = e.touches[1].clientX - t.clientX;
const py = e.touches[1].clientY - t.clientY;
const dist = Math.hypot(px, py);
const scale = dist / state.current.startDist;
handlers.onPinch && handlers.onPinch({ scale, dx, dy });
return;
}
handlers.onPan && handlers.onPan({ dx, dy, x: t.clientX, y: t.clientY });
state.current.lx = t.clientX;
state.current.ly = t.clientY;
state.current.lt = Date.now();
};
const onTouchEnd = (e) => {
reset();
const dx = state.current.lx - state.current.sx;
const dy = state.current.ly - state.current.sy;
const dt = Date.now() - state.current.st;
handlers.onPanEnd && handlers.onPanEnd({ dx, dy });
if (state.current.pinching) {
state.current.pinching = false;
return;
}
if (state.current.moved && dt < 500) {
const absX = Math.abs(dx), absY = Math.abs(dy);
if (absX > 50 || absY > 50) {
if (absX > absY) {
if (dx > 0) handlers.onSwipeRight && handlers.onSwipeRight({ dx, dt });
else handlers.onSwipeLeft && handlers.onSwipeLeft({ dx, dt });
} else {
if (dy > 0) handlers.onSwipeDown && handlers.onSwipeDown({ dy, dt });
else handlers.onSwipeUp && handlers.onSwipeUp({ dy, dt });
}
}
} else if (!state.current.moved && dt < 200) {
// Tap / DoubleTap
const now = Date.now();
const pos = { x: state.current.lx, y: state.current.ly };
const lp = state.current.lastTapPos;
if (now - state.current.lastTap < 300 && lp && Math.hypot(pos.x - lp.x, pos.y - lp.y) < 30) {
handlers.onDoubleTap && handlers.onDoubleTap(pos);
state.current.lastTap = 0;
} else {
handlers.onTap && handlers.onTap(pos);
state.current.lastTap = now;
state.current.lastTapPos = pos;
}
}
};
return { onTouchStart, onTouchMove, onTouchEnd };
}
/* ============================================================
GestureZone — zone tactile de test
Affiche le dernier geste détecté + un journal des gestes.
Toutes les actions sont nommées explicitement.
============================================================ */
function GestureZone({ label, accept = [] }) {
const [last, setLast] = uG(null);
const [log, setLog] = uG([]);
const [count, setCount] = uG({});
const [trail, setTrail] = uG(null);
const fire = (name, data) => {
setLast({ name, data, time: Date.now() });
setLog((l) => [{ name, t: new Date().toLocaleTimeString('fr-FR', { hour12: false }) }, ...l].slice(0, 5));
setCount((c) => ({ ...c, [name]: (c[name] || 0) + 1 }));
};
const hAll = {
onTap: () => fire('Tap'),
onDoubleTap: () => fire('DoubleTap'),
onLongPress: () => fire('LongPress'),
onSwipeLeft: () => fire('SwipeLeft'),
onSwipeRight: () => fire('SwipeRight'),
onSwipeUp: () => fire('SwipeUp'),
onSwipeDown: () => fire('SwipeDown'),
onPan: ({ dx, dy }) => setTrail({ dx, dy }),
onPanEnd: () => setTrail(null),
onPinch: ({ scale }) => fire('Pinch', { scale: scale.toFixed(2) }),
};
// Filtre uniquement les handlers demandés
const h = accept.length === 0 ? hAll : Object.fromEntries(
Object.entries(hAll).filter(([k]) => accept.some((n) => k.toLowerCase().includes(n.toLowerCase())))
);
const gesture = useGesture(h);
return (
<div style={{
background: 'var(--bg-3)',
border: '1px solid var(--border-2)',
borderRadius: 12,
overflow: 'hidden',
boxShadow: 'var(--tile-3d)',
marginBottom: 12,
}}>
{label && (
<div style={{
padding: '10px 14px',
fontFamily: 'var(--font-mono)', fontSize: 11,
letterSpacing: '0.08em', textTransform: 'uppercase',
color: 'var(--ink-3)',
background: 'var(--bg-2)',
borderBottom: '1px solid var(--border-1)',
}}>{label}</div>
)}
<div {...gesture}
style={{
height: 200,
position: 'relative',
background: `repeating-linear-gradient(45deg, var(--bg-3) 0 14px, var(--bg-4) 14px 15px)`,
touchAction: 'none',
userSelect: 'none',
WebkitUserSelect: 'none',
cursor: 'grab',
display: 'flex', alignItems: 'center', justifyContent: 'center',
overflow: 'hidden',
}}>
{/* indicateur central */}
<div style={{
fontFamily: 'var(--font-mono)', fontSize: 13,
color: 'var(--ink-3)', textAlign: 'center',
padding: 16, pointerEvents: 'none',
}}>
{last ? (
<div style={{
animation: 'gp-pop .3s cubic-bezier(.3,.7,.3,1.2)',
fontSize: 22, fontWeight: 700, color: 'var(--accent)',
fontFamily: 'var(--font-ui)',
}}>
{last.name}
{last.data && (
<div style={{ fontSize: 11, color: 'var(--ink-3)', fontFamily: 'var(--font-mono)', marginTop: 4 }}>
{Object.entries(last.data).map(([k, v]) => `${k}: ${v}`).join(' · ')}
</div>
)}
</div>
) : (
<span>essaie un geste ici</span>
)}
<style>{`@keyframes gp-pop { from { opacity: 0; transform: scale(.85) } to { opacity: 1; transform: scale(1) } }`}</style>
</div>
{/* trail visuel pendant le pan */}
{trail && (
<div style={{
position: 'absolute',
top: '50%', left: '50%',
width: 14, height: 14,
borderRadius: '50%',
background: 'var(--accent)',
boxShadow: '0 0 12px var(--accent-glow)',
transform: `translate(calc(-50% + ${trail.dx}px), calc(-50% + ${trail.dy}px))`,
pointerEvents: 'none',
}} />
)}
</div>
{/* Journal */}
{log.length > 0 && (
<div style={{
padding: '8px 14px 10px',
background: 'var(--bg-2)',
borderTop: '1px solid var(--border-1)',
fontFamily: 'var(--font-mono)', fontSize: 11,
color: 'var(--ink-3)',
display: 'flex', flexDirection: 'column', gap: 2,
}}>
<div style={{
display: 'flex', justifyContent: 'space-between',
fontSize: 9, letterSpacing: '0.08em', textTransform: 'uppercase',
color: 'var(--ink-4)', marginBottom: 4,
}}>
<span>journal</span>
<span>{log.length} dernier{log.length > 1 ? 's' : ''}</span>
</div>
{log.map((l, i) => (
<div key={i} style={{ opacity: 1 - i * 0.15 }}>
<span style={{ color: 'var(--ink-4)' }}>{l.t}</span>{' '}
<span style={{ color: 'var(--accent)' }}>{l.name}</span>
</div>
))}
</div>
)}
</div>
);
}
/* Catalogue des gestes — utile pour affichage + onglet d'aide */
const GESTURE_CATALOG = [
{ name: 'Tap', icon: 'play', desc: 'Pression rapide sur l\'écran.', usage: 'Action principale (équiv. clic).' },
{ name: 'DoubleTap', icon: 'plus', desc: 'Deux Tap rapprochés (<300 ms).', usage: 'Zoomer une image, liker (style Instagram).' },
{ name: 'LongPress', icon: 'clock', desc: 'Pression maintenue ≥ 500 ms.', usage: 'Ouvrir un menu contextuel, sélectionner.' },
{ name: 'SwipeLeft', icon: 'chevL', desc: 'Glisser le doigt vers la gauche.', usage: 'Naviguer à l\'écran suivant, supprimer une ligne.' },
{ name: 'SwipeRight', icon: 'chevR', desc: 'Glisser le doigt vers la droite.', usage: 'Retour à l\'écran précédent, archiver.' },
{ name: 'SwipeUp', icon: 'chevU', desc: 'Glisser vers le haut.', usage: 'Voir plus de détails, fermer une popup.' },
{ name: 'SwipeDown', icon: 'chevD', desc: 'Glisser vers le bas.', usage: 'Rafraîchir (PullToRefresh), fermer une BottomSheet.' },
{ name: 'Pan', icon: 'grid', desc: 'Glisser en continu (drag).', usage: 'Déplacer un élément, scroll horizontal.' },
{ name: 'Pinch', icon: 'search', desc: 'Écarter / rapprocher 2 doigts.', usage: 'Zoomer une carte, une image.' },
];
Object.assign(window, { useGesture, GestureZone, GESTURE_CATALOG });