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>
408 lines
15 KiB
React
408 lines
15 KiB
React
/* ============================================================
|
|
mobile-kit.jsx
|
|
Composants mobile-first du design system.
|
|
Tous nommés explicitement et exposés sur window.
|
|
Tactile-ready : hit targets ≥ 44px, animations fluides,
|
|
pas de hover, feedback au touch.
|
|
============================================================ */
|
|
|
|
const { useState: uM, useRef: rM, useEffect: eM } = React;
|
|
|
|
/* ============================================================
|
|
StatusBar — barre de statut iOS-like (en haut de l'écran)
|
|
Nom système : StatusBar
|
|
Usage : décor en haut de toute page mobile.
|
|
============================================================ */
|
|
function StatusBar({ time = '14:02', battery = 78, signal = 4 }) {
|
|
return (
|
|
<div style={{
|
|
height: 44, flex: '0 0 auto',
|
|
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
|
padding: '0 22px',
|
|
fontFamily: 'var(--font-mono)', fontSize: 14, fontWeight: 600,
|
|
color: 'var(--ink-1)',
|
|
}}>
|
|
<span>{time}</span>
|
|
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
|
|
{/* signal bars */}
|
|
<span style={{ display: 'inline-flex', alignItems: 'flex-end', gap: 1.5 }}>
|
|
{[1, 2, 3, 4].map((b) => (
|
|
<span key={b} style={{
|
|
width: 3, height: 3 + b * 2, borderRadius: 1,
|
|
background: b <= signal ? 'var(--ink-1)' : 'var(--ink-4)',
|
|
}} />
|
|
))}
|
|
</span>
|
|
<Icon name="network" size={13} />
|
|
{/* battery */}
|
|
<span style={{
|
|
width: 24, height: 11, borderRadius: 3,
|
|
border: '1px solid var(--ink-1)',
|
|
position: 'relative', marginLeft: 2,
|
|
}}>
|
|
<span style={{
|
|
position: 'absolute', top: 1, left: 1, bottom: 1,
|
|
width: `calc((100% - 2px) * ${battery / 100})`,
|
|
background: battery < 20 ? 'var(--err)' : 'var(--ink-1)',
|
|
borderRadius: 1,
|
|
}} />
|
|
<span style={{
|
|
position: 'absolute', right: -3, top: 3, bottom: 3,
|
|
width: 2, background: 'var(--ink-1)',
|
|
borderRadius: '0 1px 1px 0',
|
|
}} />
|
|
</span>
|
|
</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/* ============================================================
|
|
NavBar — barre de navigation en haut (titre + actions)
|
|
Nom système : NavBar
|
|
Usage : titre d'écran avec retour optionnel à gauche, actions à droite.
|
|
============================================================ */
|
|
function NavBar({ title, subtitle, onBack, right, large }) {
|
|
return (
|
|
<div style={{
|
|
flex: '0 0 auto',
|
|
padding: large ? '8px 16px 16px' : '8px 12px',
|
|
display: 'flex', flexDirection: 'column', gap: 4,
|
|
background: 'var(--surf-glass-strong)',
|
|
backdropFilter: 'blur(14px) saturate(150%)',
|
|
borderBottom: '1px solid var(--border-2)',
|
|
}}>
|
|
<div style={{ display: 'flex', alignItems: 'center', minHeight: 36, gap: 8 }}>
|
|
{onBack && (
|
|
<button onClick={onBack} style={{
|
|
width: 36, height: 36, borderRadius: 8,
|
|
background: 'transparent', border: 'none',
|
|
color: 'var(--accent)',
|
|
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
|
cursor: 'pointer', padding: 0,
|
|
}}>
|
|
<Icon name="chevL" size={20} />
|
|
</button>
|
|
)}
|
|
<div style={{ flex: 1, minWidth: 0 }}>
|
|
{!large && (
|
|
<div style={{ fontSize: 17, fontWeight: 700, color: 'var(--ink-1)', textAlign: onBack ? 'center' : 'left' }}>
|
|
{title}
|
|
</div>
|
|
)}
|
|
</div>
|
|
{right && <div style={{ display: 'flex', gap: 4 }}>{right}</div>}
|
|
</div>
|
|
{large && (
|
|
<div style={{ padding: '4px 0' }}>
|
|
<div style={{ fontSize: 32, fontWeight: 700, lineHeight: 1.1 }}>{title}</div>
|
|
{subtitle && <div style={{ fontSize: 13, color: 'var(--ink-3)', marginTop: 4 }}>{subtitle}</div>}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/* ============================================================
|
|
TabBar — barre d'onglets en bas (iOS/Android)
|
|
Nom système : TabBar
|
|
Usage : navigation principale entre 3-5 sections de l'app.
|
|
============================================================ */
|
|
function TabBar({ items, active, onSelect }) {
|
|
return (
|
|
<div style={{
|
|
flex: '0 0 auto',
|
|
display: 'flex', justifyContent: 'space-around', alignItems: 'stretch',
|
|
padding: '6px 8px 18px',
|
|
background: 'var(--surf-glass-strong)',
|
|
backdropFilter: 'blur(14px) saturate(150%)',
|
|
borderTop: '1px solid var(--border-2)',
|
|
}}>
|
|
{items.map((it) => {
|
|
const isActive = active === it.id;
|
|
return (
|
|
<button key={it.id} onClick={() => onSelect(it.id)} style={{
|
|
flex: 1, minHeight: 50,
|
|
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
|
|
gap: 3, padding: 0,
|
|
background: 'transparent', border: 'none',
|
|
color: isActive ? 'var(--accent)' : 'var(--ink-3)',
|
|
cursor: 'pointer',
|
|
transition: 'color .2s, transform .12s',
|
|
transform: isActive ? 'translateY(-1px)' : 'translateY(0)',
|
|
}}>
|
|
<Icon name={it.icon} size={22} />
|
|
<span style={{
|
|
fontFamily: 'var(--font-mono)', fontSize: 10,
|
|
letterSpacing: '0.04em', textTransform: 'uppercase',
|
|
fontWeight: isActive ? 700 : 500,
|
|
}}>{it.label}</span>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/* ============================================================
|
|
ListRow — ligne d'une liste réglages (style iOS)
|
|
Nom système : ListRow
|
|
Usage : option dans une liste de réglages. ≥ 44px de hauteur.
|
|
============================================================ */
|
|
function ListRow({ icon, iconColor, label, value, right, onClick, danger }) {
|
|
const isInteractive = !!onClick;
|
|
const Tag = isInteractive ? 'button' : 'div';
|
|
return (
|
|
<Tag onClick={onClick} style={{
|
|
width: '100%',
|
|
minHeight: 52,
|
|
display: 'flex', alignItems: 'center', gap: 12,
|
|
padding: '10px 14px',
|
|
background: 'transparent',
|
|
border: 'none', borderBottom: '1px solid var(--border-1)',
|
|
color: danger ? 'var(--err)' : 'var(--ink-1)',
|
|
cursor: isInteractive ? 'pointer' : 'default',
|
|
textAlign: 'left',
|
|
transition: 'background .12s',
|
|
WebkitTapHighlightColor: 'transparent',
|
|
}}
|
|
onTouchStart={isInteractive ? (e) => e.currentTarget.style.background = 'var(--bg-3)' : undefined}
|
|
onTouchEnd={isInteractive ? (e) => e.currentTarget.style.background = 'transparent' : undefined}>
|
|
{icon && (
|
|
<span style={{
|
|
width: 30, height: 30, borderRadius: 7,
|
|
background: iconColor || 'var(--bg-4)',
|
|
color: iconColor ? 'var(--bg-1)' : 'var(--ink-1)',
|
|
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
|
flex: '0 0 auto',
|
|
boxShadow: 'inset 0 1px 0 rgba(255,255,255,0.2)',
|
|
}}>
|
|
<Icon name={icon} size={15} />
|
|
</span>
|
|
)}
|
|
<span style={{ flex: 1, fontSize: 15, fontWeight: 500 }}>{label}</span>
|
|
{value && <span style={{ fontSize: 14, color: 'var(--ink-3)' }}>{value}</span>}
|
|
{right === undefined && onClick && <Icon name="chevR" size={14} style={{ color: 'var(--ink-3)' }} />}
|
|
{right}
|
|
</Tag>
|
|
);
|
|
}
|
|
|
|
/* ============================================================
|
|
ListSection — groupe de ListRow avec titre
|
|
Nom système : ListSection
|
|
============================================================ */
|
|
function ListSection({ title, hint, children }) {
|
|
return (
|
|
<div style={{ marginBottom: 18 }}>
|
|
{title && (
|
|
<div style={{
|
|
padding: '0 16px 6px',
|
|
fontFamily: 'var(--font-mono)', fontSize: 10,
|
|
letterSpacing: '0.08em', textTransform: 'uppercase',
|
|
color: 'var(--ink-3)',
|
|
}}>{title}</div>
|
|
)}
|
|
<div style={{
|
|
background: 'var(--bg-3)',
|
|
border: '1px solid var(--border-2)',
|
|
borderRadius: 10,
|
|
margin: '0 12px',
|
|
overflow: 'hidden',
|
|
boxShadow: 'var(--shadow-1)',
|
|
}}>{children}</div>
|
|
{hint && (
|
|
<div style={{
|
|
padding: '6px 16px 0', fontSize: 12, color: 'var(--ink-3)',
|
|
lineHeight: 1.4,
|
|
}}>{hint}</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/* ============================================================
|
|
ActionCard — grosse carte d'action tactile
|
|
Nom système : ActionCard
|
|
Usage : actions principales sur écran d'accueil.
|
|
============================================================ */
|
|
function ActionCard({ icon, iconColor, title, subtitle, value, onClick, badge }) {
|
|
return (
|
|
<button onClick={onClick} className="touch-press" style={{
|
|
flex: 1, minWidth: 0, minHeight: 110,
|
|
padding: 14,
|
|
background: 'var(--bg-3)',
|
|
border: '1px solid var(--border-2)',
|
|
borderRadius: 14,
|
|
color: 'var(--ink-1)',
|
|
textAlign: 'left',
|
|
display: 'flex', flexDirection: 'column', gap: 6,
|
|
cursor: 'pointer',
|
|
boxShadow: 'var(--tile-3d)',
|
|
position: 'relative',
|
|
WebkitTapHighlightColor: 'transparent',
|
|
}}>
|
|
<span style={{
|
|
width: 38, height: 38, borderRadius: 9,
|
|
background: `linear-gradient(135deg, ${iconColor || 'var(--accent)'}, color-mix(in oklch, ${iconColor || 'var(--accent)'} 60%, black))`,
|
|
color: 'var(--bg-1)',
|
|
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
|
boxShadow: 'inset 0 1px 0 rgba(255,255,255,0.25), 0 2px 6px rgba(0,0,0,0.3)',
|
|
}}>
|
|
<Icon name={icon} size={18} />
|
|
</span>
|
|
<span style={{ fontSize: 15, fontWeight: 600, lineHeight: 1.2 }}>{title}</span>
|
|
{subtitle && <span style={{ fontSize: 12, color: 'var(--ink-3)' }}>{subtitle}</span>}
|
|
{value && (
|
|
<span className="mono" style={{ fontSize: 22, fontWeight: 700, marginTop: 'auto' }}>{value}</span>
|
|
)}
|
|
{badge && (
|
|
<span style={{
|
|
position: 'absolute', top: 10, right: 10,
|
|
minWidth: 18, height: 18, borderRadius: 9,
|
|
padding: '0 6px',
|
|
background: 'var(--err)', color: 'var(--bg-1)',
|
|
fontFamily: 'var(--font-mono)', fontSize: 10, fontWeight: 700,
|
|
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
|
}}>{badge}</span>
|
|
)}
|
|
</button>
|
|
);
|
|
}
|
|
|
|
/* ============================================================
|
|
PrimaryButton — gros bouton plein largeur tactile
|
|
Nom système : PrimaryButton
|
|
Usage : action principale d'un écran (sauvegarder, valider).
|
|
============================================================ */
|
|
function PrimaryButton({ children, icon, onClick, variant = 'primary', size = 'lg' }) {
|
|
const sizes = {
|
|
md: { h: 44, fontSize: 14 },
|
|
lg: { h: 52, fontSize: 16 },
|
|
}[size];
|
|
const styles = {
|
|
primary: { bg: 'var(--accent)', fg: 'var(--bg-1)', bd: 'var(--accent-soft)' },
|
|
ghost: { bg: 'var(--bg-3)', fg: 'var(--ink-1)', bd: 'var(--border-2)' },
|
|
danger: { bg: 'var(--err)', fg: '#fff', bd: 'var(--err)' },
|
|
}[variant];
|
|
return (
|
|
<button onClick={onClick} className="touch-press" style={{
|
|
width: '100%',
|
|
height: sizes.h,
|
|
background: styles.bg,
|
|
color: styles.fg,
|
|
border: `1px solid ${styles.bd}`,
|
|
borderRadius: 12,
|
|
fontFamily: 'var(--font-ui)', fontSize: sizes.fontSize, fontWeight: 600,
|
|
cursor: 'pointer',
|
|
display: 'inline-flex', alignItems: 'center', justifyContent: 'center', gap: 8,
|
|
boxShadow: variant === 'primary' ? '0 4px 12px var(--accent-glow), inset 0 1px 0 rgba(255,255,255,0.2)' : 'var(--shadow-1)',
|
|
WebkitTapHighlightColor: 'transparent',
|
|
}}>
|
|
{icon && <Icon name={icon} size={18} />}
|
|
{children}
|
|
</button>
|
|
);
|
|
}
|
|
|
|
/* ============================================================
|
|
SegmentedControl — sélecteur segmenté iOS-style
|
|
Nom système : SegmentedControl
|
|
Usage : 2-4 options exclusives, jamais plus.
|
|
============================================================ */
|
|
function SegmentedControl({ value, onChange, options }) {
|
|
return (
|
|
<div style={{
|
|
display: 'flex',
|
|
background: 'var(--bg-1)',
|
|
border: '1px solid var(--border-2)',
|
|
borderRadius: 9,
|
|
padding: 3,
|
|
gap: 2,
|
|
boxShadow: 'inset 0 1px 2px rgba(0,0,0,0.25)',
|
|
}}>
|
|
{options.map((o) => {
|
|
const v = typeof o === 'string' ? o : o.value;
|
|
const l = typeof o === 'string' ? o : o.label;
|
|
const ic = typeof o === 'string' ? null : o.icon;
|
|
const active = value === v;
|
|
return (
|
|
<button key={v} onClick={() => onChange(v)} style={{
|
|
flex: 1, minHeight: 36,
|
|
padding: '6px 10px',
|
|
background: active ? 'var(--accent)' : 'transparent',
|
|
color: active ? 'var(--bg-1)' : 'var(--ink-2)',
|
|
border: 'none', borderRadius: 6,
|
|
cursor: 'pointer',
|
|
display: 'inline-flex', alignItems: 'center', justifyContent: 'center', gap: 5,
|
|
fontFamily: 'var(--font-ui)', fontSize: 13, fontWeight: 600,
|
|
transition: 'background .18s, color .18s, transform .12s',
|
|
transform: active ? 'translateY(-0.5px)' : 'translateY(0)',
|
|
boxShadow: active ? '0 2px 5px var(--accent-glow)' : 'none',
|
|
WebkitTapHighlightColor: 'transparent',
|
|
}}>
|
|
{ic && <Icon name={ic} size={13} />}
|
|
{l}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/* ============================================================
|
|
SearchBar — champ de recherche mobile
|
|
Nom système : SearchBar
|
|
============================================================ */
|
|
function SearchBar({ value, onChange, placeholder = 'Rechercher' }) {
|
|
return (
|
|
<div style={{
|
|
display: 'flex', alignItems: 'center', gap: 8,
|
|
padding: '10px 12px',
|
|
background: 'var(--bg-3)',
|
|
border: '1px solid var(--border-2)',
|
|
borderRadius: 10,
|
|
boxShadow: 'inset 0 1px 2px rgba(0,0,0,0.25)',
|
|
}}>
|
|
<Icon name="search" size={15} style={{ color: 'var(--ink-3)' }} />
|
|
<input type="text" value={value} onChange={(e) => onChange(e.target.value)}
|
|
placeholder={placeholder}
|
|
style={{
|
|
flex: 1, minWidth: 0,
|
|
background: 'transparent', border: 'none', outline: 'none',
|
|
color: 'var(--ink-1)', fontFamily: 'var(--font-ui)', fontSize: 15,
|
|
}} />
|
|
{value && (
|
|
<button onClick={() => onChange('')} style={{
|
|
width: 22, height: 22, borderRadius: '50%',
|
|
border: 'none', background: 'var(--ink-4)', color: 'var(--bg-1)',
|
|
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
|
cursor: 'pointer', padding: 0,
|
|
}}><Icon name="close" size={10} /></button>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
Object.assign(window, {
|
|
StatusBar, NavBar, TabBar, ListRow, ListSection,
|
|
ActionCard, PrimaryButton, SegmentedControl, SearchBar,
|
|
});
|
|
|
|
/* Effets tactiles : pression au touch (pas de hover) */
|
|
(function injectMobileFX() {
|
|
if (document.getElementById('mobile-fx')) return;
|
|
const s = document.createElement('style');
|
|
s.id = 'mobile-fx';
|
|
s.textContent = `
|
|
.touch-press {
|
|
transition: transform .08s ease-out, filter .08s, box-shadow .08s;
|
|
}
|
|
.touch-press:active {
|
|
transform: scale(0.97);
|
|
filter: brightness(0.92);
|
|
}
|
|
`;
|
|
document.head.appendChild(s);
|
|
})();
|