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>
This commit is contained in:
@@ -0,0 +1,390 @@
|
||||
/* ============================================================
|
||||
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,
|
||||
});
|
||||
Reference in New Issue
Block a user