Files
home_hub/design_system/package-smartphone/examples/mobile-sheets.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

391 lines
14 KiB
React

/* ============================================================
mobile-sheets.jsx
Types de fenêtres mobiles + composants spécifiques.
Chaque type a un nom système ET un cas d'usage préconisé.
============================================================ */
const { useState: uS, useRef: rS, useEffect: eS } = React;
/* ============================================================
BottomSheet — feuille modale qui monte du bas
Nom système : BottomSheet
Cas d'usage : action contextuelle, formulaire court, choix
dans une liste. À privilégier sur mobile à la
place d'une popup centrée (plus accessible au pouce).
Gestes : swipe down pour fermer.
============================================================ */
function BottomSheet({ open, onClose, title, children, footer, height = 'auto' }) {
const [dragY, setDragY] = uS(0);
const [closing, setClosing] = uS(false);
const startY = rS(0);
eS(() => {
if (open) { setDragY(0); setClosing(false); }
}, [open]);
if (!open && !closing) return null;
const onStart = (e) => {
startY.current = (e.touches ? e.touches[0].clientY : e.clientY);
};
const onMove = (e) => {
const y = (e.touches ? e.touches[0].clientY : e.clientY);
const d = Math.max(0, y - startY.current);
setDragY(d);
};
const onEnd = () => {
if (dragY > 80) {
setClosing(true);
setTimeout(() => { setClosing(false); setDragY(0); onClose(); }, 200);
} else {
setDragY(0);
}
};
return (
<div onClick={onClose} style={{
position: 'absolute', inset: 0, zIndex: 200,
background: `rgba(0,0,0,${closing ? 0 : 0.5 * (1 - dragY / 400)})`,
transition: 'background .2s',
display: 'flex', alignItems: 'flex-end',
}}>
<div onClick={(e) => e.stopPropagation()} style={{
width: '100%',
maxHeight: '85%',
height: height === 'auto' ? 'auto' : height,
background: 'var(--bg-2)',
borderTop: '1px solid var(--border-2)',
borderRadius: '20px 20px 0 0',
boxShadow: '0 -8px 32px rgba(0,0,0,0.5)',
transform: `translateY(${closing ? '100%' : dragY + 'px'})`,
transition: closing ? 'transform .2s ease-in' : (dragY === 0 ? 'transform .3s cubic-bezier(.3,.7,.3,1.2)' : 'none'),
display: 'flex', flexDirection: 'column',
overflow: 'hidden',
}}>
{/* Drag handle */}
<div onTouchStart={onStart} onTouchMove={onMove} onTouchEnd={onEnd}
onMouseDown={onStart}
style={{
padding: '10px 0 6px',
display: 'flex', justifyContent: 'center',
cursor: 'grab', touchAction: 'none',
}}>
<div style={{
width: 36, height: 5, borderRadius: 3,
background: 'var(--ink-4)',
}}/>
</div>
{title && (
<div style={{
padding: '0 18px 12px',
display: 'flex', alignItems: 'center', gap: 8,
borderBottom: '1px solid var(--border-1)',
}}>
<div style={{ flex: 1, fontSize: 17, fontWeight: 700 }}>{title}</div>
<button onClick={onClose} style={{
width: 30, height: 30, borderRadius: '50%',
background: 'var(--bg-4)', border: 'none', color: 'var(--ink-2)',
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
cursor: 'pointer', padding: 0,
WebkitTapHighlightColor: 'transparent',
}}><Icon name="close" size={12} /></button>
</div>
)}
<div style={{ flex: 1, overflowY: 'auto', padding: 16 }}>{children}</div>
{footer && (
<div style={{
padding: '12px 16px 22px',
borderTop: '1px solid var(--border-1)',
display: 'flex', gap: 8,
}}>{footer}</div>
)}
</div>
</div>
);
}
/* ============================================================
ActionSheet — menu d'actions style iOS
Nom système : ActionSheet
Cas d'usage : choix parmi 2-6 actions sur un élément
(équivalent menu contextuel desktop).
============================================================ */
function ActionSheet({ open, onClose, title, actions, cancelLabel = 'Annuler' }) {
if (!open) return null;
return (
<div onClick={onClose} style={{
position: 'absolute', inset: 0, zIndex: 200,
background: 'rgba(0,0,0,0.5)',
display: 'flex', alignItems: 'flex-end',
padding: 10,
animation: 'as-fade .2s',
}}>
<style>{`
@keyframes as-fade { from { opacity: 0 } to { opacity: 1 } }
@keyframes as-slide { from { transform: translateY(100%) } to { transform: translateY(0) } }
`}</style>
<div onClick={(e) => e.stopPropagation()} style={{
width: '100%',
display: 'flex', flexDirection: 'column', gap: 8,
animation: 'as-slide .25s cubic-bezier(.3,.7,.3,1.2)',
}}>
<div style={{
background: 'var(--bg-3)',
border: '1px solid var(--border-2)',
borderRadius: 14,
overflow: 'hidden',
boxShadow: 'var(--shadow-3)',
}}>
{title && (
<div style={{
padding: '12px 16px',
fontSize: 12, color: 'var(--ink-3)',
textAlign: 'center',
borderBottom: '1px solid var(--border-1)',
}}>{title}</div>
)}
{actions.map((a, i) => (
<button key={i} onClick={() => { onClose(); a.onClick && a.onClick(); }}
className="touch-press"
style={{
width: '100%', minHeight: 52,
background: 'transparent', border: 'none',
borderTop: i === 0 || (title && i === 0) ? 'none' : '1px solid var(--border-1)',
color: a.danger ? 'var(--err)' : 'var(--accent)',
fontFamily: 'var(--font-ui)', fontSize: 16, fontWeight: a.primary ? 700 : 500,
cursor: 'pointer',
display: 'inline-flex', alignItems: 'center', justifyContent: 'center', gap: 8,
WebkitTapHighlightColor: 'transparent',
}}>
{a.icon && <Icon name={a.icon} size={16} />}
{a.label}
</button>
))}
</div>
<button onClick={onClose} className="touch-press" style={{
width: '100%', minHeight: 52,
background: 'var(--bg-3)',
border: '1px solid var(--border-2)',
borderRadius: 14,
color: 'var(--accent)',
fontFamily: 'var(--font-ui)', fontSize: 16, fontWeight: 700,
cursor: 'pointer',
boxShadow: 'var(--shadow-2)',
}}>{cancelLabel}</button>
</div>
</div>
);
}
/* ============================================================
AlertDialog — alerte modale centrée
Nom système : AlertDialog
Cas d'usage : message critique, demande de confirmation
ferme (suppression, déconnexion).
============================================================ */
function AlertDialog({ open, onClose, icon, iconColor, title, message, actions }) {
if (!open) return null;
return (
<div style={{
position: 'absolute', inset: 0, zIndex: 200,
background: 'rgba(0,0,0,0.55)',
backdropFilter: 'blur(4px)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
padding: 24,
animation: 'as-fade .2s',
}}>
<div style={{
width: '100%', maxWidth: 320,
background: 'var(--bg-3)',
border: '1px solid var(--border-2)',
borderRadius: 18,
overflow: 'hidden',
boxShadow: 'var(--shadow-3)',
animation: 'pop-in .25s cubic-bezier(.3,.7,.3,1.2)',
}}>
<style>{`@keyframes pop-in { from { opacity: 0; transform: scale(.92) } to { opacity: 1; transform: scale(1) } }`}</style>
<div style={{
padding: '22px 22px 18px',
textAlign: 'center',
}}>
{icon && (
<div style={{
width: 48, height: 48, borderRadius: '50%',
background: `color-mix(in oklch, ${iconColor || 'var(--accent)'} 18%, transparent)`,
color: iconColor || 'var(--accent)',
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
marginBottom: 12,
}}>
<Icon name={icon} size={24} />
</div>
)}
<div style={{ fontSize: 17, fontWeight: 700, color: 'var(--ink-1)', marginBottom: 6 }}>{title}</div>
{message && <div style={{ fontSize: 14, color: 'var(--ink-2)', lineHeight: 1.4 }}>{message}</div>}
</div>
<div style={{
display: 'flex',
borderTop: '1px solid var(--border-1)',
}}>
{actions.map((a, i) => (
<button key={i} onClick={() => { onClose(); a.onClick && a.onClick(); }}
className="touch-press"
style={{
flex: 1, minHeight: 46,
background: 'transparent', border: 'none',
borderLeft: i > 0 ? '1px solid var(--border-1)' : 'none',
color: a.danger ? 'var(--err)' : 'var(--accent)',
fontFamily: 'var(--font-ui)', fontSize: 15,
fontWeight: a.primary ? 700 : 500,
cursor: 'pointer',
WebkitTapHighlightColor: 'transparent',
}}>{a.label}</button>
))}
</div>
</div>
</div>
);
}
/* ============================================================
Toast — notification éphémère en haut
Nom système : Toast
Cas d'usage : feedback succès/erreur après une action.
Disparaît automatiquement après 2.5s.
============================================================ */
function Toast({ open, onClose, icon, message, variant = 'ok', duration = 2500 }) {
eS(() => {
if (open) {
const t = setTimeout(onClose, duration);
return () => clearTimeout(t);
}
}, [open, duration, onClose]);
if (!open) return null;
const colors = {
ok: { bg: 'var(--ok)', fg: 'var(--bg-1)', icon: 'play' },
warn: { bg: 'var(--warn)', fg: 'var(--bg-1)', icon: 'alert' },
err: { bg: 'var(--err)', fg: '#fff', icon: 'close' },
info: { bg: 'var(--info)', fg: 'var(--bg-1)', icon: 'bell' },
}[variant];
return (
<div style={{
position: 'absolute', top: 50, left: 16, right: 16, zIndex: 300,
padding: '12px 16px',
background: colors.bg,
color: colors.fg,
borderRadius: 12,
display: 'flex', alignItems: 'center', gap: 10,
boxShadow: '0 8px 24px rgba(0,0,0,0.4)',
animation: 'toast-in .3s cubic-bezier(.3,.7,.3,1.2)',
fontFamily: 'var(--font-ui)', fontSize: 14, fontWeight: 600,
}}>
<style>{`@keyframes toast-in { from { opacity: 0; transform: translateY(-12px) } to { opacity: 1; transform: translateY(0) } }`}</style>
<Icon name={icon || colors.icon} size={18} />
<span style={{ flex: 1 }}>{message}</span>
</div>
);
}
/* ============================================================
FAB — Floating Action Button (Android Material)
Nom système : FAB
Cas d'usage : action principale unique sur un écran
(créer, ajouter). Toujours en bas à droite.
============================================================ */
function FAB({ icon, label, onClick }) {
return (
<button onClick={onClick} className="touch-press" style={{
position: 'absolute', bottom: 90, right: 18,
width: 56, height: 56, borderRadius: '50%',
background: 'var(--accent)',
color: 'var(--bg-1)',
border: 'none',
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
cursor: 'pointer',
boxShadow: '0 6px 18px var(--accent-glow), inset 0 1px 0 rgba(255,255,255,0.25), 0 2px 6px rgba(0,0,0,0.4)',
zIndex: 50,
WebkitTapHighlightColor: 'transparent',
}} aria-label={label}>
<Icon name={icon} size={22} />
</button>
);
}
/* ============================================================
PullToRefresh — wrapper pour rafraîchir au pull-down
Nom système : PullToRefresh
Geste associé : swipe down depuis le haut du contenu.
============================================================ */
function PullToRefresh({ onRefresh, children }) {
const [pull, setPull] = uS(0);
const [refreshing, setRefreshing] = uS(false);
const startY = rS(0);
const wrap = rS();
const onStart = (e) => {
if (wrap.current && wrap.current.scrollTop === 0) {
startY.current = e.touches[0].clientY;
} else {
startY.current = null;
}
};
const onMove = (e) => {
if (startY.current == null) return;
const d = e.touches[0].clientY - startY.current;
if (d > 0) setPull(Math.min(d, 100));
};
const onEnd = async () => {
if (pull > 60 && !refreshing) {
setRefreshing(true);
setPull(60);
try { await Promise.resolve(onRefresh && onRefresh()); }
finally {
await new Promise((r) => setTimeout(r, 600));
setRefreshing(false);
setPull(0);
}
} else {
setPull(0);
}
startY.current = null;
};
return (
<div ref={wrap}
onTouchStart={onStart} onTouchMove={onMove} onTouchEnd={onEnd}
style={{ position: 'relative', overflowY: 'auto', height: '100%', WebkitOverflowScrolling: 'touch' }}>
{/* indicateur */}
<div style={{
position: 'absolute', top: -20 + pull, left: 0, right: 0,
display: 'flex', justifyContent: 'center',
transition: pull === 0 || pull === 60 ? 'top .2s ease-out' : 'none',
pointerEvents: 'none',
zIndex: 10,
}}>
<div style={{
width: 32, height: 32, borderRadius: '50%',
background: 'var(--bg-3)',
border: '1px solid var(--border-2)',
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
color: 'var(--accent)',
boxShadow: 'var(--shadow-2)',
}}>
<Icon name="refresh" size={14} style={{
transform: `rotate(${pull * 4}deg)`,
animation: refreshing ? 'spin 1s linear infinite' : 'none',
transition: refreshing ? 'none' : 'transform .1s linear',
}} />
<style>{`@keyframes spin { to { transform: rotate(360deg) } }`}</style>
</div>
</div>
<div style={{
transform: `translateY(${pull}px)`,
transition: pull === 0 || pull === 60 ? 'transform .2s ease-out' : 'none',
}}>{children}</div>
</div>
);
}
Object.assign(window, {
BottomSheet, ActionSheet, AlertDialog, Toast, FAB, PullToRefresh,
});