4518ed8311
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>
391 lines
14 KiB
React
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,
|
|
});
|