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,659 @@
|
||||
/* ============================================================
|
||||
mobile-apps.jsx
|
||||
Composants pour patterns d'app courants : avatar+menu,
|
||||
onboarding, chat, calendrier, maps, recherche+filtres,
|
||||
scanner QR, caméra, gestion fichiers.
|
||||
============================================================ */
|
||||
|
||||
const { useState: uA, useRef: rA, useEffect: eA } = React;
|
||||
|
||||
/* ============================================================
|
||||
Avatar — bouton rond utilisateur (initiales ou icône)
|
||||
Nom système : Avatar
|
||||
============================================================ */
|
||||
function Avatar({ name = 'M', color = 'var(--accent)', size = 36, onClick, active }) {
|
||||
const initials = name.split(' ').map(w => w[0]).slice(0, 2).join('').toUpperCase();
|
||||
return (
|
||||
<button onClick={onClick} className="touch-press" style={{
|
||||
width: size, height: size, borderRadius: '50%',
|
||||
background: `linear-gradient(135deg, ${color}, color-mix(in oklch, ${color} 60%, black))`,
|
||||
color: 'var(--bg-1)',
|
||||
border: active ? '2px solid var(--accent)' : 'none',
|
||||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontFamily: 'var(--font-ui)', fontSize: size * 0.4, fontWeight: 700,
|
||||
cursor: 'pointer',
|
||||
boxShadow: 'inset 0 1px 0 rgba(255,255,255,0.25), 0 2px 6px rgba(0,0,0,0.3)',
|
||||
WebkitTapHighlightColor: 'transparent',
|
||||
}}>{initials}</button>
|
||||
);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
AvatarMenu — popup descendant depuis l'avatar
|
||||
Nom système : AvatarMenu
|
||||
Items : [{icon, label, onClick, danger}]
|
||||
============================================================ */
|
||||
function AvatarMenu({ open, onClose, name, email, items = [] }) {
|
||||
if (!open) return null;
|
||||
return (
|
||||
<div onClick={onClose} style={{
|
||||
position: 'absolute', inset: 0, zIndex: 200,
|
||||
background: 'rgba(0,0,0,0.35)',
|
||||
animation: 'fade-in .15s',
|
||||
}}>
|
||||
<style>{`
|
||||
@keyframes fade-in { from { opacity: 0 } to { opacity: 1 } }
|
||||
@keyframes drop-in { from { opacity: 0; transform: translateY(-8px) scale(.95) } to { opacity: 1; transform: translateY(0) scale(1) } }
|
||||
`}</style>
|
||||
<div onClick={(e) => e.stopPropagation()} style={{
|
||||
position: 'absolute', top: 56, right: 12,
|
||||
width: 240,
|
||||
background: 'var(--bg-3)',
|
||||
border: '1px solid var(--border-2)',
|
||||
borderRadius: 14,
|
||||
overflow: 'hidden',
|
||||
boxShadow: '0 14px 32px rgba(0,0,0,0.5)',
|
||||
animation: 'drop-in .2s cubic-bezier(.3,.7,.3,1.2)',
|
||||
transformOrigin: 'top right',
|
||||
}}>
|
||||
<div style={{
|
||||
padding: '14px 14px 12px',
|
||||
display: 'flex', alignItems: 'center', gap: 10,
|
||||
borderBottom: '1px solid var(--border-1)',
|
||||
background: 'var(--bg-2)',
|
||||
}}>
|
||||
<Avatar name={name} size={36} />
|
||||
<div style={{ minWidth: 0, flex: 1 }}>
|
||||
<div style={{ fontSize: 14, fontWeight: 700 }}>{name}</div>
|
||||
{email && <div style={{ fontSize: 11, color: 'var(--ink-3)', fontFamily: 'var(--font-mono)' }}>{email}</div>}
|
||||
</div>
|
||||
</div>
|
||||
{items.map((it, i) => (
|
||||
<button key={i} onClick={() => { onClose(); it.onClick && it.onClick(); }}
|
||||
className="touch-press" style={{
|
||||
width: '100%', minHeight: 44,
|
||||
padding: '10px 14px',
|
||||
background: 'transparent', border: 'none',
|
||||
borderTop: i > 0 ? '1px solid var(--border-1)' : 'none',
|
||||
color: it.danger ? 'var(--err)' : 'var(--ink-1)',
|
||||
display: 'flex', alignItems: 'center', gap: 10,
|
||||
fontFamily: 'var(--font-ui)', fontSize: 14, fontWeight: 500,
|
||||
cursor: 'pointer', textAlign: 'left',
|
||||
WebkitTapHighlightColor: 'transparent',
|
||||
}}>
|
||||
<Icon name={it.icon} size={15} style={{ color: it.danger ? 'var(--err)' : 'var(--accent)' }} />
|
||||
<span style={{ flex: 1 }}>{it.label}</span>
|
||||
{!it.danger && <Icon name="chevR" size={12} style={{ color: 'var(--ink-3)' }} />}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
OnboardingSlider — slides + dots + boutons suivant/passer
|
||||
Nom système : OnboardingSlider
|
||||
Cas : présentation d'une nouvelle app à l'utilisateur.
|
||||
slides : [{icon, color, title, desc}]
|
||||
============================================================ */
|
||||
function OnboardingSlider({ slides, onFinish }) {
|
||||
const [i, setI] = uA(0);
|
||||
const isLast = i === slides.length - 1;
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||
<div style={{
|
||||
padding: '14px 20px',
|
||||
display: 'flex', justifyContent: 'flex-end',
|
||||
}}>
|
||||
<button onClick={onFinish} style={{
|
||||
padding: '6px 12px', background: 'transparent', border: 'none',
|
||||
color: 'var(--ink-3)', fontFamily: 'var(--font-ui)',
|
||||
fontWeight: 600, fontSize: 14, cursor: 'pointer',
|
||||
WebkitTapHighlightColor: 'transparent',
|
||||
}}>Passer</button>
|
||||
</div>
|
||||
<div style={{
|
||||
flex: 1, padding: '0 32px',
|
||||
display: 'flex', flexDirection: 'column',
|
||||
alignItems: 'center', justifyContent: 'center',
|
||||
textAlign: 'center',
|
||||
}}>
|
||||
<div style={{
|
||||
width: 110, height: 110, borderRadius: 28,
|
||||
background: `linear-gradient(135deg, ${slides[i].color}, color-mix(in oklch, ${slides[i].color} 60%, black))`,
|
||||
color: 'var(--bg-1)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
marginBottom: 28,
|
||||
boxShadow: `inset 0 2px 0 rgba(255,255,255,0.2), 0 12px 28px rgba(0,0,0,0.4)`,
|
||||
animation: 'pop-in .35s cubic-bezier(.3,.7,.3,1.3)',
|
||||
}}>
|
||||
<style>{`@keyframes pop-in { from { transform: scale(.7); opacity: 0 } }`}</style>
|
||||
<Icon name={slides[i].icon} size={56} />
|
||||
</div>
|
||||
<div style={{ fontSize: 26, fontWeight: 700, marginBottom: 12 }}>{slides[i].title}</div>
|
||||
<div style={{ fontSize: 15, color: 'var(--ink-3)', lineHeight: 1.5, maxWidth: 280 }}>{slides[i].desc}</div>
|
||||
</div>
|
||||
<div style={{ padding: '20px 24px 30px' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'center', gap: 8, marginBottom: 22 }}>
|
||||
{slides.map((_, j) => (
|
||||
<span key={j} onClick={() => setI(j)} style={{
|
||||
width: i === j ? 24 : 8, height: 8, borderRadius: 4,
|
||||
background: i === j ? 'var(--accent)' : 'var(--border-3)',
|
||||
transition: 'width .25s, background .2s',
|
||||
cursor: 'pointer',
|
||||
}} />
|
||||
))}
|
||||
</div>
|
||||
<PrimaryButton icon={isLast ? 'play' : 'chevR'}
|
||||
onClick={() => isLast ? onFinish() : setI(i + 1)}>
|
||||
{isLast ? 'Commencer' : 'Suivant'}
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
ChatBubble — bulle de message (envoyé/reçu)
|
||||
Nom système : ChatBubble
|
||||
============================================================ */
|
||||
function ChatBubble({ text, time, me, status }) {
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: me ? 'flex-end' : 'flex-start',
|
||||
padding: '4px 14px',
|
||||
}}>
|
||||
<div style={{
|
||||
maxWidth: '78%',
|
||||
padding: '8px 12px',
|
||||
background: me ? 'var(--accent)' : 'var(--bg-3)',
|
||||
color: me ? 'var(--bg-1)' : 'var(--ink-1)',
|
||||
borderRadius: me ? '16px 16px 4px 16px' : '16px 16px 16px 4px',
|
||||
fontSize: 14, lineHeight: 1.4,
|
||||
boxShadow: me ? '0 2px 6px var(--accent-glow)' : 'var(--shadow-1)',
|
||||
border: me ? 'none' : '1px solid var(--border-2)',
|
||||
}}>
|
||||
<div>{text}</div>
|
||||
<div style={{
|
||||
fontSize: 10,
|
||||
color: me ? 'rgba(0,0,0,0.55)' : 'var(--ink-3)',
|
||||
marginTop: 4, textAlign: 'right',
|
||||
fontFamily: 'var(--font-mono)',
|
||||
display: 'inline-flex', alignItems: 'center', gap: 4,
|
||||
float: 'right',
|
||||
}}>
|
||||
{time}
|
||||
{me && status === 'sent' && <span>✓</span>}
|
||||
{me && status === 'read' && <span>✓✓</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
ChatComposer — barre d'envoi en bas (input + + + send)
|
||||
Nom système : ChatComposer
|
||||
============================================================ */
|
||||
function ChatComposer({ onSend }) {
|
||||
const [v, setV] = uA('');
|
||||
return (
|
||||
<div style={{
|
||||
padding: '8px 10px 18px',
|
||||
display: 'flex', alignItems: 'flex-end', gap: 8,
|
||||
borderTop: '1px solid var(--border-2)',
|
||||
background: 'var(--surf-glass-strong)',
|
||||
backdropFilter: 'blur(14px)',
|
||||
}}>
|
||||
<IconButton icon="plus" label="Joindre" size={36} />
|
||||
<div style={{
|
||||
flex: 1, minHeight: 36,
|
||||
display: 'flex', alignItems: 'center', gap: 6,
|
||||
padding: '6px 12px',
|
||||
background: 'var(--bg-3)',
|
||||
border: '1px solid var(--border-2)',
|
||||
borderRadius: 18,
|
||||
}}>
|
||||
<input type="text" value={v} onChange={(e) => setV(e.target.value)}
|
||||
placeholder="Message…"
|
||||
style={{
|
||||
flex: 1, minWidth: 0,
|
||||
background: 'transparent', border: 'none', outline: 'none',
|
||||
color: 'var(--ink-1)', fontFamily: 'var(--font-ui)', fontSize: 14,
|
||||
}} />
|
||||
</div>
|
||||
{v ? (
|
||||
<button onClick={() => { onSend && onSend(v); setV(''); }}
|
||||
className="touch-press" style={{
|
||||
width: 36, height: 36, borderRadius: '50%',
|
||||
background: 'var(--accent)', color: 'var(--bg-1)',
|
||||
border: 'none', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
cursor: 'pointer', boxShadow: '0 2px 6px var(--accent-glow)',
|
||||
WebkitTapHighlightColor: 'transparent',
|
||||
}}><Icon name="chevR" size={16} /></button>
|
||||
) : (
|
||||
<IconButton icon="terminal" label="Audio" size={36} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
CalendarMonth — vue mois avec points sous les jours marqués
|
||||
Nom système : CalendarMonth
|
||||
Props : year, month (0-11), selected (Date), onSelect, events (Set de jours)
|
||||
============================================================ */
|
||||
function CalendarMonth({ year, month, selected, onSelect, events = new Set() }) {
|
||||
const today = new Date();
|
||||
const first = new Date(year, month, 1);
|
||||
const last = new Date(year, month + 1, 0);
|
||||
const startDay = (first.getDay() + 6) % 7; // lundi = 0
|
||||
const days = last.getDate();
|
||||
const cells = [];
|
||||
for (let i = 0; i < startDay; i++) cells.push(null);
|
||||
for (let d = 1; d <= days; d++) cells.push(d);
|
||||
const monthName = first.toLocaleDateString('fr-FR', { month: 'long', year: 'numeric' });
|
||||
return (
|
||||
<div>
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||
padding: '0 14px 12px',
|
||||
}}>
|
||||
<IconButton icon="chevL" label="Mois précédent" size={32} />
|
||||
<div style={{ fontSize: 16, fontWeight: 700, textTransform: 'capitalize' }}>{monthName}</div>
|
||||
<IconButton icon="chevR" label="Mois suivant" size={32} />
|
||||
</div>
|
||||
<div style={{
|
||||
display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)', gap: 4,
|
||||
padding: '0 8px',
|
||||
}}>
|
||||
{['L', 'M', 'M', 'J', 'V', 'S', 'D'].map((d, i) => (
|
||||
<div key={i} style={{
|
||||
textAlign: 'center', fontSize: 10,
|
||||
color: 'var(--ink-3)', fontFamily: 'var(--font-mono)',
|
||||
fontWeight: 700, padding: '4px 0',
|
||||
letterSpacing: '0.08em',
|
||||
}}>{d}</div>
|
||||
))}
|
||||
{cells.map((d, i) => {
|
||||
const isToday = d === today.getDate() && month === today.getMonth() && year === today.getFullYear();
|
||||
const isSel = selected && d === selected.getDate() && month === selected.getMonth() && year === selected.getFullYear();
|
||||
const hasEvent = d && events.has(d);
|
||||
return (
|
||||
<button key={i} onClick={() => d && onSelect && onSelect(new Date(year, month, d))}
|
||||
disabled={!d}
|
||||
className="touch-press"
|
||||
style={{
|
||||
aspectRatio: '1',
|
||||
background: isSel ? 'var(--accent)' : isToday ? 'var(--accent-tint)' : 'transparent',
|
||||
color: isSel ? 'var(--bg-1)' : isToday ? 'var(--accent)' : (d ? 'var(--ink-1)' : 'transparent'),
|
||||
border: 'none', borderRadius: 8,
|
||||
fontFamily: 'var(--font-mono)', fontSize: 13,
|
||||
fontWeight: isSel || isToday ? 700 : 500,
|
||||
cursor: d ? 'pointer' : 'default',
|
||||
position: 'relative',
|
||||
WebkitTapHighlightColor: 'transparent',
|
||||
}}>
|
||||
{d}
|
||||
{hasEvent && (
|
||||
<span style={{
|
||||
position: 'absolute', bottom: 4, left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
width: 4, height: 4, borderRadius: '50%',
|
||||
background: isSel ? 'var(--bg-1)' : 'var(--accent)',
|
||||
}}/>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
MapView — placeholder visuel d'une carte avec pins
|
||||
Nom système : MapView
|
||||
============================================================ */
|
||||
function MapView({ pins = [] }) {
|
||||
return (
|
||||
<div style={{
|
||||
position: 'relative',
|
||||
height: '100%', width: '100%',
|
||||
background: 'var(--bg-2)',
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
{/* fond carte stylisé */}
|
||||
<svg width="100%" height="100%" viewBox="0 0 400 600" preserveAspectRatio="xMidYMid slice" style={{ position: 'absolute', inset: 0 }}>
|
||||
<defs>
|
||||
<pattern id="grid" width="40" height="40" patternUnits="userSpaceOnUse">
|
||||
<path d="M 40 0 L 0 0 0 40" fill="none" stroke="var(--border-1)" strokeWidth="0.5"/>
|
||||
</pattern>
|
||||
</defs>
|
||||
<rect width="100%" height="100%" fill="url(#grid)"/>
|
||||
{/* routes */}
|
||||
<path d="M 0 200 Q 200 150 400 250" stroke="var(--ink-4)" strokeWidth="6" fill="none" opacity="0.3"/>
|
||||
<path d="M 100 0 Q 150 200 200 400 T 250 600" stroke="var(--ink-4)" strokeWidth="6" fill="none" opacity="0.3"/>
|
||||
<path d="M 200 100 L 350 500" stroke="var(--ink-4)" strokeWidth="4" fill="none" opacity="0.25"/>
|
||||
{/* zones */}
|
||||
<path d="M 0 0 L 150 0 L 100 120 L 0 100 Z" fill="var(--bg-3)" opacity="0.5"/>
|
||||
<path d="M 280 350 L 400 380 L 400 550 L 320 600 L 250 500 Z" fill="var(--bg-3)" opacity="0.4"/>
|
||||
<circle cx="240" cy="380" r="60" fill="var(--ok)" opacity="0.12"/>
|
||||
{/* fleuve */}
|
||||
<path d="M 0 450 Q 100 420 200 460 T 400 440" stroke="var(--info)" strokeWidth="10" fill="none" opacity="0.4"/>
|
||||
</svg>
|
||||
{/* pins */}
|
||||
{pins.map((p, i) => (
|
||||
<div key={i} style={{
|
||||
position: 'absolute', left: `${p.x}%`, top: `${p.y}%`,
|
||||
transform: 'translate(-50%, -100%)',
|
||||
pointerEvents: 'none',
|
||||
}}>
|
||||
<div style={{
|
||||
width: 28, height: 28, borderRadius: '50% 50% 50% 0',
|
||||
background: p.color || 'var(--accent)',
|
||||
transform: 'rotate(-45deg)',
|
||||
border: '2px solid var(--bg-1)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
boxShadow: '0 4px 10px rgba(0,0,0,0.5)',
|
||||
}}>
|
||||
<Icon name={p.icon || 'grid'} size={12} style={{ color: 'var(--bg-1)', transform: 'rotate(45deg)' }}/>
|
||||
</div>
|
||||
{p.label && (
|
||||
<div style={{
|
||||
position: 'absolute', top: -28, left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
padding: '3px 8px',
|
||||
background: 'var(--bg-3)',
|
||||
border: '1px solid var(--border-2)',
|
||||
borderRadius: 6,
|
||||
fontFamily: 'var(--font-mono)', fontSize: 10,
|
||||
color: 'var(--ink-1)',
|
||||
whiteSpace: 'nowrap',
|
||||
boxShadow: 'var(--shadow-2)',
|
||||
}}>{p.label}</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
FilterChips — barre de chips de filtre
|
||||
Nom système : FilterChips
|
||||
============================================================ */
|
||||
function FilterChips({ value = [], onChange, options }) {
|
||||
const toggle = (v) => {
|
||||
if (value.includes(v)) onChange(value.filter((x) => x !== v));
|
||||
else onChange([...value, v]);
|
||||
};
|
||||
return (
|
||||
<div style={{ display: 'flex', gap: 6, overflowX: 'auto', padding: '4px 0', WebkitOverflowScrolling: 'touch' }}>
|
||||
{options.map((o) => {
|
||||
const v = typeof o === 'string' ? o : o.value;
|
||||
const l = typeof o === 'string' ? o : o.label;
|
||||
const ic = typeof o === 'object' ? o.icon : null;
|
||||
const active = value.includes(v);
|
||||
return (
|
||||
<button key={v} onClick={() => toggle(v)} className="touch-press" style={{
|
||||
flex: '0 0 auto',
|
||||
padding: '6px 12px',
|
||||
background: active ? 'var(--accent)' : 'var(--bg-3)',
|
||||
color: active ? 'var(--bg-1)' : 'var(--ink-2)',
|
||||
border: `1px solid ${active ? 'var(--accent)' : 'var(--border-2)'}`,
|
||||
borderRadius: 999,
|
||||
display: 'inline-flex', alignItems: 'center', gap: 6,
|
||||
cursor: 'pointer',
|
||||
fontFamily: 'var(--font-ui)', fontSize: 12, fontWeight: 600,
|
||||
WebkitTapHighlightColor: 'transparent',
|
||||
}}>
|
||||
{ic && <Icon name={ic} size={12} />}
|
||||
{l}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
QrScannerView — viseur scanner code-barres / QR
|
||||
Nom système : QrScannerView
|
||||
============================================================ */
|
||||
function QrScannerView({ onCapture }) {
|
||||
return (
|
||||
<div style={{
|
||||
position: 'relative', width: '100%', height: '100%',
|
||||
background: '#000',
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
{/* fake camera feed = grain animé */}
|
||||
<div style={{
|
||||
position: 'absolute', inset: 0,
|
||||
background: `
|
||||
radial-gradient(ellipse at 30% 40%, rgba(80,60,40,0.4), transparent 60%),
|
||||
radial-gradient(ellipse at 70% 60%, rgba(40,40,60,0.5), transparent 50%),
|
||||
#15110c
|
||||
`,
|
||||
}}/>
|
||||
{/* visée centrale */}
|
||||
<div style={{
|
||||
position: 'absolute', top: '50%', left: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
width: 220, height: 220,
|
||||
}}>
|
||||
{/* 4 coins */}
|
||||
{[
|
||||
{ top: 0, left: 0, br: '4px 0 0 0' },
|
||||
{ top: 0, right: 0, br: '0 4px 0 0' },
|
||||
{ bottom: 0, left: 0, br: '0 0 0 4px' },
|
||||
{ bottom: 0, right: 0, br: '0 0 4px 0' },
|
||||
].map((c, i) => (
|
||||
<div key={i} style={{
|
||||
position: 'absolute', ...c, width: 28, height: 28,
|
||||
borderTop: c.top !== undefined ? '3px solid var(--accent)' : 'none',
|
||||
borderBottom: c.bottom !== undefined ? '3px solid var(--accent)' : 'none',
|
||||
borderLeft: c.left !== undefined ? '3px solid var(--accent)' : 'none',
|
||||
borderRight: c.right !== undefined ? '3px solid var(--accent)' : 'none',
|
||||
borderRadius: c.br,
|
||||
}}/>
|
||||
))}
|
||||
{/* ligne scan animée */}
|
||||
<div style={{
|
||||
position: 'absolute', left: 6, right: 6, height: 2,
|
||||
background: 'linear-gradient(90deg, transparent, var(--accent), transparent)',
|
||||
boxShadow: '0 0 12px var(--accent), 0 0 20px var(--accent)',
|
||||
animation: 'qr-scan 2.4s ease-in-out infinite',
|
||||
}}/>
|
||||
<style>{`@keyframes qr-scan {
|
||||
0%, 100% { top: 6px; opacity: 1 }
|
||||
50% { top: calc(100% - 8px); opacity: 0.7 }
|
||||
}`}</style>
|
||||
</div>
|
||||
{/* overlay assombri hors visée */}
|
||||
<div style={{
|
||||
position: 'absolute', inset: 0,
|
||||
boxShadow: '0 0 0 9999px rgba(0,0,0,0.55) inset',
|
||||
clipPath: 'polygon(0% 0%, 0% 100%, 100% 100%, 100% 0%, calc(50% + 110px) 0%, calc(50% + 110px) calc(50% + 110px), calc(50% - 110px) calc(50% + 110px), calc(50% - 110px) calc(50% - 110px), calc(50% + 110px) calc(50% - 110px), calc(50% + 110px) 0%)',
|
||||
pointerEvents: 'none',
|
||||
}}/>
|
||||
{/* texte */}
|
||||
<div style={{
|
||||
position: 'absolute', top: 'calc(50% + 140px)', left: 0, right: 0,
|
||||
textAlign: 'center', color: 'var(--ink-1)',
|
||||
fontFamily: 'var(--font-ui)', fontSize: 14, fontWeight: 600,
|
||||
}}>Pointe vers un QR code ou code-barres</div>
|
||||
{/* boutons bas */}
|
||||
<div style={{
|
||||
position: 'absolute', bottom: 28, left: 0, right: 0,
|
||||
display: 'flex', justifyContent: 'space-around', alignItems: 'center',
|
||||
}}>
|
||||
<IconButton icon="folder" label="Galerie" size={44} />
|
||||
<button onClick={() => onCapture && onCapture('demo')} className="touch-press" style={{
|
||||
width: 70, height: 70, borderRadius: '50%',
|
||||
background: 'var(--accent)', border: '4px solid #fff',
|
||||
color: 'var(--bg-1)', cursor: 'pointer',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
boxShadow: '0 4px 12px var(--accent-glow)',
|
||||
WebkitTapHighlightColor: 'transparent',
|
||||
}}><Icon name="grid" size={26} /></button>
|
||||
<IconButton icon="moon" label="Flash" size={44} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
CameraView — viseur appareil photo avec shutter rond
|
||||
Nom système : CameraView
|
||||
============================================================ */
|
||||
function CameraView({ onShoot }) {
|
||||
return (
|
||||
<div style={{
|
||||
position: 'relative', width: '100%', height: '100%',
|
||||
background: '#000', overflow: 'hidden',
|
||||
}}>
|
||||
{/* fake scene */}
|
||||
<div style={{
|
||||
position: 'absolute', inset: 0,
|
||||
background: `
|
||||
linear-gradient(180deg, #4a2e1a 0%, #6b4423 30%, #2a1f15 70%, #15110c 100%),
|
||||
radial-gradient(circle at 50% 30%, rgba(254,128,25,0.3), transparent 50%)
|
||||
`,
|
||||
backgroundBlendMode: 'overlay',
|
||||
}}/>
|
||||
{/* règle des tiers */}
|
||||
<div style={{ position: 'absolute', inset: 0, pointerEvents: 'none' }}>
|
||||
{[33.33, 66.66].map((p) => (
|
||||
<React.Fragment key={p}>
|
||||
<div style={{ position:'absolute', left:0, right:0, top:`${p}%`, height:1, background:'rgba(255,255,255,0.2)' }}/>
|
||||
<div style={{ position:'absolute', top:0, bottom:0, left:`${p}%`, width:1, background:'rgba(255,255,255,0.2)' }}/>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
{/* top bar */}
|
||||
<div style={{
|
||||
position: 'absolute', top: 20, left: 0, right: 0,
|
||||
display: 'flex', justifyContent: 'space-around',
|
||||
padding: '0 16px',
|
||||
}}>
|
||||
{[
|
||||
{ icon: 'moon', label: 'Flash' },
|
||||
{ icon: 'clock', label: 'Minuteur' },
|
||||
{ icon: 'grid', label: 'Grille' },
|
||||
].map((b) => (
|
||||
<IconButton key={b.label} icon={b.icon} label={b.label} size={36} />
|
||||
))}
|
||||
</div>
|
||||
{/* mode chips */}
|
||||
<div style={{
|
||||
position: 'absolute', bottom: 130, left: 0, right: 0,
|
||||
display: 'flex', justifyContent: 'center', gap: 20,
|
||||
color: 'var(--ink-2)', fontFamily: 'var(--font-mono)', fontSize: 12,
|
||||
letterSpacing: '0.08em', textTransform: 'uppercase',
|
||||
}}>
|
||||
<span style={{ opacity: 0.5 }}>Vidéo</span>
|
||||
<span style={{ color: 'var(--accent)', fontWeight: 700 }}>Photo</span>
|
||||
<span style={{ opacity: 0.5 }}>Portrait</span>
|
||||
</div>
|
||||
{/* bottom controls */}
|
||||
<div style={{
|
||||
position: 'absolute', bottom: 28, left: 0, right: 0,
|
||||
display: 'flex', justifyContent: 'space-around', alignItems: 'center',
|
||||
}}>
|
||||
<div style={{
|
||||
width: 50, height: 50, borderRadius: 10,
|
||||
background: 'linear-gradient(135deg, #6b4423, #2a1f15)',
|
||||
border: '2px solid #fff',
|
||||
}}/>
|
||||
<button onClick={() => onShoot && onShoot()} className="touch-press" style={{
|
||||
width: 76, height: 76, borderRadius: '50%',
|
||||
background: '#fff', border: '4px solid rgba(255,255,255,0.4)',
|
||||
cursor: 'pointer',
|
||||
boxShadow: '0 0 0 4px rgba(0,0,0,0.4)',
|
||||
WebkitTapHighlightColor: 'transparent',
|
||||
}}/>
|
||||
<IconButton icon="refresh" label="Caméra avant" size={44} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
FileExplorer — liste fichiers/dossiers
|
||||
Nom système : FileExplorer
|
||||
============================================================ */
|
||||
function FileExplorer({ items, onOpen, onAction }) {
|
||||
const sizeFmt = (b) => {
|
||||
if (b == null) return '';
|
||||
if (b < 1024) return `${b} o`;
|
||||
if (b < 1024 * 1024) return `${(b / 1024).toFixed(1)} Ko`;
|
||||
if (b < 1024 ** 3) return `${(b / 1024 / 1024).toFixed(1)} Mo`;
|
||||
return `${(b / 1024 / 1024 / 1024).toFixed(1)} Go`;
|
||||
};
|
||||
const typeIcon = (t) => ({
|
||||
folder: 'folder', image: 'grid', video: 'play', audio: 'terminal',
|
||||
pdf: 'list', code: 'terminal', archive: 'download', file: 'list',
|
||||
})[t] || 'list';
|
||||
const typeColor = (t) => ({
|
||||
folder: 'var(--accent)', image: 'var(--blue)', video: 'var(--purple)',
|
||||
audio: 'var(--ok)', pdf: 'var(--err)', code: 'var(--info)', archive: 'var(--warn)',
|
||||
})[t] || 'var(--ink-3)';
|
||||
return (
|
||||
<div>
|
||||
{items.map((it) => (
|
||||
<SwipeableRow key={it.name}
|
||||
onTap={() => onOpen && onOpen(it)}
|
||||
leftActions={[
|
||||
{ label: 'Suppr.', icon: 'close', color: 'var(--err)',
|
||||
onClick: () => onAction && onAction('delete', it) },
|
||||
]}
|
||||
rightActions={[
|
||||
{ label: 'Renom.', icon: 'cog', color: 'var(--info)',
|
||||
onClick: () => onAction && onAction('rename', it) },
|
||||
{ label: 'Partag.', icon: 'download', color: 'var(--accent)',
|
||||
onClick: () => onAction && onAction('share', it) },
|
||||
]}>
|
||||
<div style={{
|
||||
padding: '12px 14px',
|
||||
display: 'flex', alignItems: 'center', gap: 12,
|
||||
borderBottom: '1px solid var(--border-1)',
|
||||
background: 'var(--bg-3)',
|
||||
}}>
|
||||
<span style={{
|
||||
width: 38, height: 38, borderRadius: 8,
|
||||
background: 'var(--bg-1)',
|
||||
border: `1px solid ${typeColor(it.type)}`,
|
||||
color: typeColor(it.type),
|
||||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||
}}>
|
||||
<Icon name={typeIcon(it.type)} size={17} />
|
||||
</span>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontSize: 14, fontWeight: 500, color: 'var(--ink-1)',
|
||||
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{it.name}</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--ink-3)', fontFamily: 'var(--font-mono)', marginTop: 2 }}>
|
||||
{it.date || ''} {it.size != null && `· ${sizeFmt(it.size)}`}
|
||||
</div>
|
||||
</div>
|
||||
{it.type === 'folder' && <Icon name="chevR" size={13} style={{ color: 'var(--ink-3)' }}/>}
|
||||
</div>
|
||||
</SwipeableRow>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Object.assign(window, {
|
||||
Avatar, AvatarMenu,
|
||||
OnboardingSlider,
|
||||
ChatBubble, ChatComposer,
|
||||
CalendarMonth,
|
||||
MapView,
|
||||
FilterChips,
|
||||
QrScannerView, CameraView,
|
||||
FileExplorer,
|
||||
});
|
||||
@@ -0,0 +1,385 @@
|
||||
/* ============================================================
|
||||
mobile-forms.jsx
|
||||
Composants de saisie mobile avec contrôle du clavier virtuel.
|
||||
Tous nommés et exposés sur window.
|
||||
============================================================ */
|
||||
|
||||
const { useState: uMF, useRef: rMF } = React;
|
||||
|
||||
/* ============================================================
|
||||
FormField — wrapper standard pour un champ
|
||||
Nom système : FormField
|
||||
Affiche : label · description · le champ · message d'erreur/hint
|
||||
============================================================ */
|
||||
function FormField({ label, hint, error, required, children }) {
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, marginBottom: 14 }}>
|
||||
{label && (
|
||||
<label style={{
|
||||
fontFamily: 'var(--font-mono)', fontSize: 11,
|
||||
letterSpacing: '0.08em', textTransform: 'uppercase',
|
||||
color: 'var(--ink-3)',
|
||||
}}>
|
||||
{label}{required && <span style={{ color: 'var(--accent)', marginLeft: 4 }}>*</span>}
|
||||
</label>
|
||||
)}
|
||||
{children}
|
||||
{(error || hint) && (
|
||||
<div style={{
|
||||
fontSize: 12,
|
||||
color: error ? 'var(--err)' : 'var(--ink-4)',
|
||||
lineHeight: 1.4,
|
||||
}}>{error || hint}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
TextInput — champ texte avec contrôle complet du clavier virtuel
|
||||
Nom système : TextInput
|
||||
Props clavier virtuel (mobile uniquement) :
|
||||
keyboard: 'text' | 'numeric' | 'tel' | 'email' | 'url' | 'search' | 'decimal' | 'none'
|
||||
autocomplete: 'name'|'email'|'tel'|'address-line1'|'postal-code'|'country'|
|
||||
'given-name'|'family-name'|'current-password'|'new-password'|
|
||||
'one-time-code'|'off'… (Web Authentication API)
|
||||
autocapitalize: 'sentences' | 'words' | 'characters' | 'off'
|
||||
spellCheck: bool
|
||||
enterHint: 'send'|'search'|'go'|'done'|'next'|'previous' (texte de la touche Entrée)
|
||||
pattern: regex de validation
|
||||
============================================================ */
|
||||
function TextInput({
|
||||
value, onChange, placeholder, type = 'text', icon, trailing,
|
||||
keyboard, autocomplete = 'off', autocapitalize = 'sentences',
|
||||
spellCheck = false, enterHint, pattern, maxLength, multiline, rows = 4,
|
||||
error,
|
||||
}) {
|
||||
const C = multiline ? 'textarea' : 'input';
|
||||
const inputProps = {
|
||||
value, onChange: (e) => onChange(e.target.value),
|
||||
placeholder,
|
||||
inputMode: keyboard,
|
||||
autoComplete: autocomplete,
|
||||
autoCapitalize: autocapitalize,
|
||||
spellCheck,
|
||||
enterKeyHint: enterHint,
|
||||
pattern, maxLength,
|
||||
rows: multiline ? rows : undefined,
|
||||
type: !multiline ? type : undefined,
|
||||
style: {
|
||||
flex: 1, minWidth: 0,
|
||||
background: 'transparent', border: 'none', outline: 'none',
|
||||
color: 'var(--ink-1)',
|
||||
fontFamily: type === 'password' ? 'var(--font-mono)' : 'var(--font-ui)',
|
||||
fontSize: 15,
|
||||
padding: multiline ? '4px 0' : 0,
|
||||
resize: multiline ? 'vertical' : undefined,
|
||||
minHeight: multiline ? rows * 22 : undefined,
|
||||
},
|
||||
};
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex', alignItems: multiline ? 'flex-start' : 'center', gap: 10,
|
||||
padding: '12px 14px',
|
||||
background: 'var(--bg-1)',
|
||||
border: `1px solid ${error ? 'var(--err)' : 'var(--border-2)'}`,
|
||||
borderRadius: 10,
|
||||
boxShadow: 'inset 0 1px 2px rgba(0,0,0,0.25)',
|
||||
}}>
|
||||
{icon && <Icon name={icon} size={16} style={{ color: 'var(--ink-3)', flex: '0 0 auto', marginTop: multiline ? 4 : 0 }} />}
|
||||
<C {...inputProps} />
|
||||
{trailing}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
DateInput — date picker natif mobile
|
||||
Nom système : DateInput
|
||||
============================================================ */
|
||||
function DateInput({ value, onChange, mode = 'date' }) {
|
||||
// mode : 'date' | 'datetime-local' | 'time' | 'month' | 'week'
|
||||
const icons = { date: 'clock', 'datetime-local': 'clock', time: 'clock', month: 'clock', week: 'clock' };
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', gap: 10,
|
||||
padding: '12px 14px',
|
||||
background: 'var(--bg-1)',
|
||||
border: '1px solid var(--border-2)',
|
||||
borderRadius: 10,
|
||||
boxShadow: 'inset 0 1px 2px rgba(0,0,0,0.25)',
|
||||
}}>
|
||||
<Icon name={icons[mode]} size={16} style={{ color: 'var(--accent)' }} />
|
||||
<input
|
||||
type={mode}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
style={{
|
||||
flex: 1, minWidth: 0,
|
||||
background: 'transparent', border: 'none', outline: 'none',
|
||||
color: 'var(--ink-1)',
|
||||
fontFamily: 'var(--font-mono)', fontSize: 15,
|
||||
colorScheme: 'dark',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
Dropdown — select natif stylisé
|
||||
Nom système : Dropdown
|
||||
============================================================ */
|
||||
function Dropdown({ value, onChange, options, placeholder = 'Choisir…' }) {
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', gap: 10,
|
||||
padding: '12px 14px',
|
||||
background: 'var(--bg-1)',
|
||||
border: '1px solid var(--border-2)',
|
||||
borderRadius: 10,
|
||||
boxShadow: 'inset 0 1px 2px rgba(0,0,0,0.25)',
|
||||
position: 'relative',
|
||||
}}>
|
||||
<select value={value} onChange={(e) => onChange(e.target.value)} style={{
|
||||
flex: 1, minWidth: 0,
|
||||
background: 'transparent', border: 'none', outline: 'none',
|
||||
color: value ? 'var(--ink-1)' : 'var(--ink-3)',
|
||||
fontFamily: 'var(--font-ui)', fontSize: 15,
|
||||
appearance: 'none', WebkitAppearance: 'none',
|
||||
paddingRight: 24,
|
||||
}}>
|
||||
<option value="">{placeholder}</option>
|
||||
{options.map((o) => (
|
||||
typeof o === 'string'
|
||||
? <option key={o} value={o}>{o}</option>
|
||||
: <option key={o.value} value={o.value}>{o.label}</option>
|
||||
))}
|
||||
</select>
|
||||
<Icon name="chevD" size={14} style={{ color: 'var(--ink-3)', position: 'absolute', right: 14, pointerEvents: 'none' }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
CheckboxItem — case à cocher (style iOS)
|
||||
Nom système : CheckboxItem
|
||||
Cas : oui/non sur une option, sélection multiple dans une liste
|
||||
============================================================ */
|
||||
function CheckboxItem({ checked, onChange, label, description }) {
|
||||
return (
|
||||
<label className="touch-press" style={{
|
||||
display: 'flex', alignItems: 'flex-start', gap: 12,
|
||||
padding: '12px 14px',
|
||||
background: 'var(--bg-3)',
|
||||
border: '1px solid var(--border-2)',
|
||||
borderRadius: 10,
|
||||
cursor: 'pointer',
|
||||
WebkitTapHighlightColor: 'transparent',
|
||||
}}>
|
||||
<span style={{
|
||||
width: 22, height: 22, borderRadius: 6,
|
||||
background: checked ? 'var(--accent)' : 'var(--bg-1)',
|
||||
border: `1.5px solid ${checked ? 'var(--accent)' : 'var(--border-3)'}`,
|
||||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||
color: 'var(--bg-1)',
|
||||
flex: '0 0 auto', marginTop: 1,
|
||||
boxShadow: 'inset 0 1px 2px rgba(0,0,0,0.2)',
|
||||
transition: 'all .12s',
|
||||
}}>
|
||||
{checked && <Icon name="play" size={11} />}
|
||||
</span>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontSize: 15, color: 'var(--ink-1)', fontWeight: 500 }}>{label}</div>
|
||||
{description && <div style={{ fontSize: 12, color: 'var(--ink-3)', marginTop: 2 }}>{description}</div>}
|
||||
</div>
|
||||
<input type="checkbox" checked={checked} onChange={(e) => onChange(e.target.checked)} style={{ display: 'none' }} />
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
RadioGroup — groupe d'options exclusives
|
||||
Nom système : RadioGroup
|
||||
============================================================ */
|
||||
function RadioGroup({ value, onChange, options }) {
|
||||
return (
|
||||
<div style={{
|
||||
background: 'var(--bg-3)',
|
||||
border: '1px solid var(--border-2)',
|
||||
borderRadius: 10,
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
{options.map((o, i) => {
|
||||
const v = typeof o === 'string' ? o : o.value;
|
||||
const l = typeof o === 'string' ? o : o.label;
|
||||
const d = typeof o === 'object' ? o.description : null;
|
||||
const active = value === v;
|
||||
return (
|
||||
<label key={v} className="touch-press" style={{
|
||||
display: 'flex', alignItems: 'center', gap: 12,
|
||||
padding: '12px 14px',
|
||||
borderTop: i > 0 ? '1px solid var(--border-1)' : 'none',
|
||||
cursor: 'pointer',
|
||||
WebkitTapHighlightColor: 'transparent',
|
||||
}}>
|
||||
<span style={{
|
||||
width: 22, height: 22, borderRadius: '50%',
|
||||
border: `2px solid ${active ? 'var(--accent)' : 'var(--border-3)'}`,
|
||||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||
flex: '0 0 auto',
|
||||
background: 'var(--bg-1)',
|
||||
}}>
|
||||
{active && <span style={{ width: 10, height: 10, borderRadius: '50%', background: 'var(--accent)' }} />}
|
||||
</span>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ fontSize: 15, color: 'var(--ink-1)', fontWeight: 500 }}>{l}</div>
|
||||
{d && <div style={{ fontSize: 12, color: 'var(--ink-3)', marginTop: 2 }}>{d}</div>}
|
||||
</div>
|
||||
<input type="radio" checked={active} onChange={() => onChange(v)} style={{ display: 'none' }} />
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
MediaInsert — boutons "insérer..." pour image/vidéo/audio/GPS
|
||||
Nom système : MediaInsert
|
||||
Cas : ajouter une pièce jointe dans un formulaire mobile.
|
||||
Note : utilise les API natives via <input type="file" accept="..." capture="..."/>
|
||||
et navigator.geolocation pour le GPS.
|
||||
============================================================ */
|
||||
function MediaInsert({ onPick }) {
|
||||
const items = [
|
||||
{ id: 'photo', icon: 'grid', label: 'Photo', hint: 'Appareil photo', accept: 'image/*', capture: 'environment' },
|
||||
{ id: 'image', icon: 'folder', label: 'Image', hint: 'Depuis la galerie', accept: 'image/*' },
|
||||
{ id: 'video', icon: 'play', label: 'Vidéo', hint: 'Caméra ou galerie', accept: 'video/*', capture: 'environment' },
|
||||
{ id: 'audio', icon: 'terminal', label: 'Audio', hint: 'Enregistrement vocal', accept: 'audio/*', capture: 'user' },
|
||||
{ id: 'file', icon: 'download', label: 'Fichier', hint: 'Doc, PDF, autre', accept: '*' },
|
||||
{ id: 'gps', icon: 'network', label: 'Position', hint: 'GPS du téléphone', special: true },
|
||||
];
|
||||
return (
|
||||
<div style={{
|
||||
display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 8,
|
||||
}}>
|
||||
{items.map((it) => (
|
||||
<label key={it.id} className="touch-press" style={{
|
||||
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
|
||||
gap: 6, padding: '14px 8px',
|
||||
background: 'var(--bg-3)',
|
||||
border: '1px solid var(--border-2)',
|
||||
borderRadius: 10,
|
||||
color: 'var(--ink-1)',
|
||||
cursor: 'pointer',
|
||||
textAlign: 'center',
|
||||
WebkitTapHighlightColor: 'transparent',
|
||||
minHeight: 72,
|
||||
}}>
|
||||
<Icon name={it.icon} size={18} style={{ color: 'var(--accent)' }} />
|
||||
<span style={{ fontSize: 12, fontWeight: 600 }}>{it.label}</span>
|
||||
<span style={{ fontSize: 10, color: 'var(--ink-3)' }}>{it.hint}</span>
|
||||
{!it.special && (
|
||||
<input type="file" accept={it.accept} capture={it.capture}
|
||||
onChange={(e) => onPick && onPick(it.id, e.target.files[0])}
|
||||
style={{ display: 'none' }} />
|
||||
)}
|
||||
{it.special && (
|
||||
<input type="button" onClick={() => {
|
||||
if (!navigator.geolocation) { onPick && onPick('gps', { error: 'GPS indisponible' }); return; }
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(pos) => onPick && onPick('gps', { lat: pos.coords.latitude, lon: pos.coords.longitude }),
|
||||
(err) => onPick && onPick('gps', { error: err.message }),
|
||||
);
|
||||
}} style={{ display: 'none' }} />
|
||||
)}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
AvatarLogo — gros logo rond pour écran de connexion
|
||||
Nom système : AvatarLogo
|
||||
============================================================ */
|
||||
function AvatarLogo({ icon = 'server', size = 80, glow = true }) {
|
||||
return (
|
||||
<div style={{
|
||||
width: size, height: size, borderRadius: size * 0.28,
|
||||
background: `linear-gradient(135deg, var(--accent), color-mix(in oklch, var(--accent) 60%, black))`,
|
||||
color: 'var(--bg-1)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
boxShadow: glow
|
||||
? `inset 0 2px 0 rgba(255,255,255,0.25), 0 8px 24px var(--accent-glow), 0 4px 12px rgba(0,0,0,0.4)`
|
||||
: 'inset 0 2px 0 rgba(255,255,255,0.25)',
|
||||
margin: '0 auto',
|
||||
}}>
|
||||
<Icon name={icon} size={size * 0.45} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
BiometricButton — bouton biométrie (Face ID / Touch ID)
|
||||
Nom système : BiometricButton
|
||||
============================================================ */
|
||||
function BiometricButton({ kind = 'face', label, onClick }) {
|
||||
const lbl = label || (kind === 'face' ? 'Face ID' : 'Touch ID');
|
||||
return (
|
||||
<button onClick={onClick} className="touch-press" style={{
|
||||
display: 'inline-flex', flexDirection: 'column', alignItems: 'center', gap: 4,
|
||||
padding: '8px 14px',
|
||||
background: 'transparent', border: 'none',
|
||||
color: 'var(--accent)', cursor: 'pointer',
|
||||
fontFamily: 'var(--font-ui)', fontSize: 13, fontWeight: 600,
|
||||
WebkitTapHighlightColor: 'transparent',
|
||||
}}>
|
||||
<Icon name={kind === 'face' ? 'user' : 'play'} size={28} />
|
||||
{lbl}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
Object.assign(window, {
|
||||
FormField, TextInput, DateInput, Dropdown,
|
||||
CheckboxItem, RadioGroup, MediaInsert,
|
||||
AvatarLogo, BiometricButton,
|
||||
});
|
||||
|
||||
/* ============================================================
|
||||
CATALOGUE KEYBOARD — pour la doc
|
||||
============================================================ */
|
||||
const KEYBOARD_CATALOG = [
|
||||
{ name: 'text', desc: 'Clavier standard (lettres + chiffres).', usage: 'Tout texte libre, noms.' },
|
||||
{ name: 'numeric', desc: 'Pavé numérique sans signe ni virgule.', usage: 'Codes PIN, OTP, références numériques.' },
|
||||
{ name: 'decimal', desc: 'Pavé numérique avec virgule/point.', usage: 'Prix, mesures, montants.' },
|
||||
{ name: 'tel', desc: 'Pavé téléphone avec + et formats.', usage: 'Numéros de téléphone.' },
|
||||
{ name: 'email', desc: 'Clavier texte avec @ et . en accès direct.', usage: 'Adresses email.' },
|
||||
{ name: 'url', desc: 'Clavier texte avec / et .com.', usage: 'URLs, liens.' },
|
||||
{ name: 'search', desc: 'Clavier standard, touche Entrée = "Rechercher".', usage: 'Champs de recherche.' },
|
||||
{ name: 'none', desc: 'Aucun clavier (utile avec un picker custom).', usage: 'Date picker custom, sélecteur de couleur, etc.' },
|
||||
];
|
||||
|
||||
const AUTOCOMPLETE_CATALOG = [
|
||||
{ name: 'name / given-name / family-name', usage: 'Nom complet, prénom, nom de famille' },
|
||||
{ name: 'email', usage: 'Adresse email (autoremplie depuis le compte iOS/Android)' },
|
||||
{ name: 'tel', usage: 'Numéro de téléphone' },
|
||||
{ name: 'address-line1 / postal-code / country', usage: 'Adresse postale' },
|
||||
{ name: 'current-password', usage: 'Mot de passe existant (Face ID/Touch ID propose le remplissage)' },
|
||||
{ name: 'new-password', usage: 'Nouveau mot de passe (le gestionnaire propose d\'en générer un)' },
|
||||
{ name: 'one-time-code', usage: 'Code OTP reçu par SMS (auto-lu sur iOS / Android)' },
|
||||
{ name: 'off', usage: 'Désactive complètement les suggestions' },
|
||||
];
|
||||
|
||||
const ENTER_HINT_CATALOG = [
|
||||
{ name: 'send', usage: 'Envoyer un message (chat, email)' },
|
||||
{ name: 'search', usage: 'Rechercher (résultat affiché en bas)' },
|
||||
{ name: 'go', usage: 'Y aller (URL, action de navigation)' },
|
||||
{ name: 'done', usage: 'Terminer la saisie et fermer le clavier' },
|
||||
{ name: 'next', usage: 'Passer au champ suivant du formulaire' },
|
||||
{ name: 'previous', usage: 'Revenir au champ précédent' },
|
||||
];
|
||||
|
||||
Object.assign(window, { KEYBOARD_CATALOG, AUTOCOMPLETE_CATALOG, ENTER_HINT_CATALOG });
|
||||
@@ -0,0 +1,286 @@
|
||||
/* ============================================================
|
||||
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 });
|
||||
@@ -0,0 +1,407 @@
|
||||
/* ============================================================
|
||||
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);
|
||||
})();
|
||||
@@ -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,
|
||||
});
|
||||
@@ -0,0 +1,137 @@
|
||||
/* ============================================================
|
||||
mobile-swipeable.jsx
|
||||
SwipeableRow — ligne qui révèle des actions au swipe.
|
||||
============================================================ */
|
||||
|
||||
const { useState: uSw, useRef: rSw, useEffect: eSw } = React;
|
||||
|
||||
/* ============================================================
|
||||
SwipeableRow
|
||||
Nom système : SwipeableRow
|
||||
Cas d'usage : ligne d'une liste avec actions cachées
|
||||
(archive, suppression, marquer comme lu…).
|
||||
Style iOS Mail / Things / Apple Reminders.
|
||||
Gestes : SwipeLeft (révèle leftActions à droite),
|
||||
SwipeRight (révèle rightActions à gauche),
|
||||
Tap sur la ligne (action principale),
|
||||
Tap sur une action (déclenche l'action puis ferme).
|
||||
============================================================ */
|
||||
function SwipeableRow({ children, leftActions = [], rightActions = [], onTap }) {
|
||||
// leftActions s'affichent quand on swipe vers la GAUCHE
|
||||
// (la ligne se décale à gauche, dévoilant les actions à DROITE)
|
||||
const [tx, setTx] = uSw(0);
|
||||
const [dragging, setDragging] = uSw(false);
|
||||
const startX = rSw(0);
|
||||
const initialTx = rSw(0);
|
||||
|
||||
const leftW = leftActions.length * 76; // actions à droite (révélées par swipe gauche)
|
||||
const rightW = rightActions.length * 76; // actions à gauche (révélées par swipe droit)
|
||||
|
||||
const snap = (x) => {
|
||||
if (x < -leftW * 0.5) setTx(-leftW);
|
||||
else if (x > rightW * 0.5) setTx(rightW);
|
||||
else setTx(0);
|
||||
};
|
||||
|
||||
const onStart = (e) => {
|
||||
setDragging(true);
|
||||
startX.current = (e.touches ? e.touches[0].clientX : e.clientX);
|
||||
initialTx.current = tx;
|
||||
};
|
||||
const onMove = (e) => {
|
||||
if (!dragging) return;
|
||||
const x = (e.touches ? e.touches[0].clientX : e.clientX);
|
||||
let d = initialTx.current + (x - startX.current);
|
||||
// limite + élasticité hors zone
|
||||
if (d > rightW) d = rightW + (d - rightW) * 0.3;
|
||||
if (d < -leftW) d = -leftW + (d + leftW) * 0.3;
|
||||
setTx(d);
|
||||
};
|
||||
const onEnd = () => {
|
||||
setDragging(false);
|
||||
snap(tx);
|
||||
};
|
||||
|
||||
const fire = (action) => {
|
||||
setTx(0);
|
||||
setTimeout(() => action.onClick && action.onClick(), 200);
|
||||
};
|
||||
|
||||
const handleTap = (e) => {
|
||||
if (tx !== 0) { setTx(0); return; }
|
||||
if (Math.abs(tx) < 4 && onTap) onTap(e);
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
background: 'var(--bg-3)',
|
||||
WebkitUserSelect: 'none', userSelect: 'none',
|
||||
}}>
|
||||
{/* Actions à GAUCHE (révélées par swipe droit) */}
|
||||
{rightActions.length > 0 && (
|
||||
<div style={{
|
||||
position: 'absolute', left: 0, top: 0, bottom: 0,
|
||||
display: 'flex', alignItems: 'stretch',
|
||||
width: rightW,
|
||||
}}>
|
||||
{rightActions.map((a, i) => (
|
||||
<button key={i} onClick={() => fire(a)} className="touch-press" style={{
|
||||
width: 76,
|
||||
background: a.color || 'var(--info)',
|
||||
color: a.fg || '#fff',
|
||||
border: 'none', cursor: 'pointer',
|
||||
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 4,
|
||||
fontFamily: 'var(--font-ui)', fontSize: 12, fontWeight: 600,
|
||||
WebkitTapHighlightColor: 'transparent',
|
||||
}}>
|
||||
{a.icon && <Icon name={a.icon} size={20} />}
|
||||
{a.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{/* Actions à DROITE (révélées par swipe gauche) */}
|
||||
{leftActions.length > 0 && (
|
||||
<div style={{
|
||||
position: 'absolute', right: 0, top: 0, bottom: 0,
|
||||
display: 'flex', alignItems: 'stretch',
|
||||
width: leftW,
|
||||
}}>
|
||||
{leftActions.map((a, i) => (
|
||||
<button key={i} onClick={() => fire(a)} className="touch-press" style={{
|
||||
width: 76,
|
||||
background: a.color || 'var(--err)',
|
||||
color: a.fg || '#fff',
|
||||
border: 'none', cursor: 'pointer',
|
||||
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 4,
|
||||
fontFamily: 'var(--font-ui)', fontSize: 12, fontWeight: 600,
|
||||
WebkitTapHighlightColor: 'transparent',
|
||||
}}>
|
||||
{a.icon && <Icon name={a.icon} size={20} />}
|
||||
{a.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{/* Ligne déplaçable */}
|
||||
<div
|
||||
onTouchStart={onStart} onTouchMove={onMove} onTouchEnd={onEnd}
|
||||
onMouseDown={onStart} onMouseMove={onMove} onMouseUp={onEnd} onMouseLeave={onEnd}
|
||||
onClick={handleTap}
|
||||
style={{
|
||||
position: 'relative',
|
||||
background: 'var(--bg-3)',
|
||||
transform: `translateX(${tx}px)`,
|
||||
transition: dragging ? 'none' : 'transform .25s cubic-bezier(.3,.7,.3,1.1)',
|
||||
cursor: dragging ? 'grabbing' : (onTap ? 'pointer' : 'default'),
|
||||
touchAction: 'pan-y',
|
||||
}}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Object.assign(window, { SwipeableRow });
|
||||
@@ -0,0 +1,656 @@
|
||||
/* ============================================================
|
||||
ui-kit.jsx
|
||||
Composants haute-fid Gruvbox Seventies.
|
||||
Tout est purement décoratif/interactif côté composant.
|
||||
Effets : transparence (glass), hover glow, click 3D, tooltips.
|
||||
============================================================ */
|
||||
|
||||
const { useState, useRef, useEffect } = React;
|
||||
|
||||
/* ============================================================
|
||||
Icônes — Font Awesome 6 Free.
|
||||
Mapping nom logique → classe FA. Le CSS de FA est chargé en CDN
|
||||
dans le <head>. Le composant garde la MÊME API qu'avant (name,
|
||||
size, style) pour ne rien casser ailleurs.
|
||||
============================================================ */
|
||||
const ICON_MAP = {
|
||||
cpu: 'microchip',
|
||||
memory: 'memory',
|
||||
disk: 'hard-drive',
|
||||
network: 'network-wired',
|
||||
clock: 'clock',
|
||||
grid: 'table-cells',
|
||||
list: 'list',
|
||||
cog: 'gear',
|
||||
alert: 'triangle-exclamation',
|
||||
bell: 'bell',
|
||||
server: 'server',
|
||||
chart: 'chart-line',
|
||||
bars: 'chart-simple',
|
||||
terminal: 'terminal',
|
||||
refresh: 'arrows-rotate',
|
||||
play: 'play',
|
||||
pause: 'pause',
|
||||
power: 'power-off',
|
||||
sun: 'sun',
|
||||
moon: 'moon',
|
||||
search: 'magnifying-glass',
|
||||
close: 'xmark',
|
||||
chevR: 'chevron-right',
|
||||
chevL: 'chevron-left',
|
||||
chevD: 'chevron-down',
|
||||
chevU: 'chevron-up',
|
||||
plus: 'plus',
|
||||
filter: 'filter',
|
||||
download: 'download',
|
||||
folder: 'folder',
|
||||
node: 'circle-nodes',
|
||||
user: 'user',
|
||||
};
|
||||
|
||||
const Icon = ({ name, size = 16, style }) => {
|
||||
const fa = ICON_MAP[name] || 'circle-question';
|
||||
return (
|
||||
<i className={`fa-solid fa-${fa}`} aria-hidden="true" style={{
|
||||
fontSize: size,
|
||||
width: size,
|
||||
height: size,
|
||||
lineHeight: `${size}px`,
|
||||
textAlign: 'center',
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flex: '0 0 auto',
|
||||
color: 'currentColor',
|
||||
...style,
|
||||
}} />
|
||||
);
|
||||
};
|
||||
|
||||
/* ============================================================
|
||||
Tooltip — apparaît au hover après 300ms, position auto.
|
||||
============================================================ */
|
||||
function Tooltip({ children, label, side = 'top' }) {
|
||||
const [show, setShow] = useState(false);
|
||||
const t = useRef();
|
||||
const onEnter = () => { t.current = setTimeout(() => setShow(true), 280); };
|
||||
const onLeave = () => { clearTimeout(t.current); setShow(false); };
|
||||
const sides = {
|
||||
top: { bottom: 'calc(100% + 8px)', left: '50%', transform: 'translateX(-50%)' },
|
||||
bottom: { top: 'calc(100% + 8px)', left: '50%', transform: 'translateX(-50%)' },
|
||||
left: { right: 'calc(100% + 8px)', top: '50%', transform: 'translateY(-50%)' },
|
||||
right: { left: 'calc(100% + 8px)', top: '50%', transform: 'translateY(-50%)' },
|
||||
};
|
||||
return (
|
||||
<span style={{ position: 'relative', display: 'inline-flex' }}
|
||||
onMouseEnter={onEnter} onMouseLeave={onLeave}>
|
||||
{children}
|
||||
{show && (
|
||||
<span className="glass-strong" style={{
|
||||
position: 'absolute', ...sides[side],
|
||||
padding: '6px 10px',
|
||||
borderRadius: 6,
|
||||
fontSize: 12, lineHeight: 1.3,
|
||||
color: 'var(--ink-1)',
|
||||
whiteSpace: 'nowrap',
|
||||
boxShadow: 'var(--shadow-2)',
|
||||
zIndex: 1000,
|
||||
pointerEvents: 'none',
|
||||
fontFamily: 'JetBrains Mono, monospace',
|
||||
letterSpacing: '0.02em',
|
||||
}}>{label}</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
IconButton — bouton icône seul + tooltip obligatoire.
|
||||
============================================================ */
|
||||
function IconButton({ icon, label, onClick, active, danger, size = 34, primary }) {
|
||||
const bg = active ? 'var(--accent-tint)'
|
||||
: primary ? 'var(--accent)'
|
||||
: 'var(--bg-3)';
|
||||
const fg = active ? 'var(--accent)'
|
||||
: primary ? 'var(--bg-1)'
|
||||
: danger ? 'var(--err)'
|
||||
: 'var(--ink-2)';
|
||||
const bd = active ? 'var(--accent-soft)' : 'var(--border-2)';
|
||||
return (
|
||||
<Tooltip label={label}>
|
||||
<button onClick={onClick} className="interactive" style={{
|
||||
width: size, height: size,
|
||||
background: bg,
|
||||
color: fg,
|
||||
border: `1px solid ${bd}`,
|
||||
borderRadius: 8,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
padding: 0, cursor: 'pointer',
|
||||
boxShadow: primary ? '0 2px 6px var(--accent-glow)' : 'var(--shadow-1)',
|
||||
}}>
|
||||
<Icon name={icon} size={Math.round(size * 0.5)} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
Toggle on/off — switch tactile avec glow accent quand ON
|
||||
============================================================ */
|
||||
function Toggle({ on, onChange, label, icon }) {
|
||||
return (
|
||||
<div style={{ display: 'inline-flex', alignItems: 'center', gap: 10 }}>
|
||||
{icon && <Icon name={icon} size={14} style={{ color: on ? 'var(--accent)' : 'var(--ink-3)' }} />}
|
||||
{label && <span className="label" style={{ color: on ? 'var(--ink-1)' : 'var(--ink-3)' }}>{label}</span>}
|
||||
<button onClick={() => onChange(!on)} className="interactive" style={{
|
||||
width: 42, height: 22, borderRadius: 12,
|
||||
background: on ? 'var(--accent)' : 'var(--bg-4)',
|
||||
border: `1px solid ${on ? 'var(--accent-soft)' : 'var(--border-2)'}`,
|
||||
boxShadow: on ? `0 0 10px var(--accent-glow), var(--shadow-1)` : 'var(--shadow-press)',
|
||||
position: 'relative', cursor: 'pointer', padding: 0,
|
||||
}}>
|
||||
<span style={{
|
||||
position: 'absolute', top: 1, left: on ? 21 : 1,
|
||||
width: 18, height: 18, borderRadius: '50%',
|
||||
background: on ? 'var(--bg-1)' : 'var(--ink-2)',
|
||||
transition: 'left .18s cubic-bezier(.5,.2,.3,1.3), background .15s',
|
||||
boxShadow: 'var(--shadow-1)',
|
||||
}} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
Status LED — pastille pulsante (effet halo si critique)
|
||||
============================================================ */
|
||||
function StatusLed({ status = 'ok', size = 10, pulse }) {
|
||||
const map = {
|
||||
ok: { c: 'var(--ok)', g: 'var(--ok-glow)' },
|
||||
warn: { c: 'var(--warn)', g: 'var(--warn-glow)' },
|
||||
err: { c: 'var(--err)', g: 'var(--err-glow)' },
|
||||
off: { c: 'var(--ink-4)', g: 'transparent' },
|
||||
info: { c: 'var(--info)', g: 'var(--info-glow)' },
|
||||
};
|
||||
const { c, g } = map[status];
|
||||
const id = `pulse-${status}-${size}`;
|
||||
return (
|
||||
<>
|
||||
{pulse && (
|
||||
<style>{`@keyframes ${id} { 0%{box-shadow:0 0 0 0 ${g}} 70%{box-shadow:0 0 0 6px transparent} 100%{box-shadow:0 0 0 0 transparent} }`}</style>
|
||||
)}
|
||||
<span style={{
|
||||
display: 'inline-block',
|
||||
width: size, height: size,
|
||||
borderRadius: '50%',
|
||||
background: c,
|
||||
boxShadow: status === 'off' ? 'none' : `0 0 6px ${g}, inset 0 0 2px rgba(0,0,0,0.3)`,
|
||||
animation: pulse ? `${id} 1.8s ease-out infinite` : 'none',
|
||||
flex: '0 0 auto',
|
||||
}} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
BatteryGauge — jauge horizontale style batterie
|
||||
- Pas de bandes (couleur unie + léger gloss interne)
|
||||
- Pas de graduations verticales
|
||||
- Hover : glow lumineux dans la couleur de la jauge
|
||||
- Mode compact : label [bar] valeur sur une seule ligne
|
||||
============================================================ */
|
||||
function BatteryGauge({ value = 60, label, max = 100, unit = '%', warnAt = 70, errAt = 90, height = 22, compact = false, color: colorOverride, icon }) {
|
||||
const pct = Math.max(0, Math.min(100, (value / max) * 100));
|
||||
const color = colorOverride
|
||||
|| (pct >= errAt ? 'var(--err)' : pct >= warnAt ? 'var(--warn)' : 'var(--ok)');
|
||||
const glowVar = pct >= errAt ? 'var(--err-glow)'
|
||||
: pct >= warnAt ? 'var(--warn-glow)'
|
||||
: 'var(--ok-glow)';
|
||||
|
||||
// Variante compacte : label [bar] valeur sur une seule ligne
|
||||
if (compact) {
|
||||
return (
|
||||
<div className="bg-hover" style={{
|
||||
display: 'flex', alignItems: 'center', gap: 10, minWidth: 0,
|
||||
'--bg-glow': glowVar,
|
||||
}}>
|
||||
{(icon || label) && (
|
||||
<span style={{
|
||||
flex: '0 0 auto', display: 'inline-flex', alignItems: 'center', gap: 6,
|
||||
minWidth: 90,
|
||||
}}>
|
||||
{icon && <Icon name={icon} size={12} style={{ color: 'var(--ink-3)' }} />}
|
||||
{label && <span className="label" style={{ fontSize: 11 }}>{label}</span>}
|
||||
</span>
|
||||
)}
|
||||
<div className="bg-bar" style={{
|
||||
flex: 1, height: 12, borderRadius: 3,
|
||||
background: 'var(--bg-1)',
|
||||
border: '1px solid var(--border-2)',
|
||||
boxShadow: 'inset 0 1px 2px rgba(0,0,0,0.4)',
|
||||
overflow: 'hidden', position: 'relative',
|
||||
transition: 'border-color .2s',
|
||||
}}>
|
||||
<div className="bg-fill" style={{
|
||||
position: 'absolute', top: 1, left: 1, bottom: 1,
|
||||
width: `calc((100% - 2px) * ${pct / 100})`,
|
||||
background: color,
|
||||
borderRadius: 2,
|
||||
transition: 'width .4s cubic-bezier(.3,.6,.3,1), box-shadow .2s',
|
||||
}} />
|
||||
</div>
|
||||
<span className="mono" style={{
|
||||
flex: '0 0 auto', fontSize: 13,
|
||||
color: 'var(--ink-1)', minWidth: 52, textAlign: 'right',
|
||||
}}>
|
||||
{value}<span style={{ color: 'var(--ink-3)', marginLeft: 2 }}>{unit}</span>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-hover" style={{
|
||||
display: 'flex', flexDirection: 'column', gap: 6, minWidth: 0,
|
||||
'--bg-glow': glowVar,
|
||||
}}>
|
||||
{label && (
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline' }}>
|
||||
<span className="label">{label}</span>
|
||||
<span className="mono" style={{ fontSize: 13, color: 'var(--ink-1)' }}>
|
||||
{value}<span style={{ color: 'var(--ink-3)', marginLeft: 2 }}>{unit}</span>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="bg-bar" style={{
|
||||
position: 'relative',
|
||||
height, borderRadius: 4,
|
||||
background: 'var(--bg-1)',
|
||||
border: '1px solid var(--border-2)',
|
||||
boxShadow: 'inset 0 1px 2px rgba(0,0,0,0.4)',
|
||||
overflow: 'hidden',
|
||||
transition: 'border-color .2s',
|
||||
}}>
|
||||
<div className="bg-fill" style={{
|
||||
position: 'absolute', top: 1, left: 1, bottom: 1,
|
||||
width: `calc((100% - 2px) * ${pct / 100})`,
|
||||
background: color,
|
||||
borderRadius: 3,
|
||||
transition: 'width .4s cubic-bezier(.3,.6,.3,1), box-shadow .2s',
|
||||
}} />
|
||||
{/* Gloss interne très léger (un seul highlight haut, pas de bande inférieure) */}
|
||||
<div style={{
|
||||
position: 'absolute', top: 1, left: 1, right: 1, height: '40%',
|
||||
background: 'linear-gradient(180deg, rgba(255,255,255,0.18), transparent)',
|
||||
borderRadius: '3px 3px 0 0',
|
||||
pointerEvents: 'none',
|
||||
}} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
RadialGauge — jauge ronde, version épurée
|
||||
============================================================ */
|
||||
function RadialGauge({ value = 64, label, size = 120, warnAt = 70, errAt = 90 }) {
|
||||
const pct = Math.max(0, Math.min(100, value));
|
||||
const color = pct >= errAt ? 'var(--err)' : pct >= warnAt ? 'var(--warn)' : 'var(--ok)';
|
||||
const glow = pct >= errAt ? 'var(--err-glow)' : pct >= warnAt ? 'var(--warn-glow)' : 'var(--ok-glow)';
|
||||
const r = size / 2 - 10;
|
||||
const cx = size / 2;
|
||||
const cy = size / 2 + 6;
|
||||
const circ = Math.PI * r;
|
||||
const offset = circ - (pct / 100) * circ;
|
||||
return (
|
||||
<div className="gauge-hover" style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 2 }}>
|
||||
<svg width={size} height={size * 0.72} viewBox={`0 0 ${size} ${size * 0.8}`}>
|
||||
<defs>
|
||||
<filter id={`glow-${label}`} x="-50%" y="-50%" width="200%" height="200%">
|
||||
<feGaussianBlur stdDeviation="2.5" />
|
||||
</filter>
|
||||
</defs>
|
||||
{/* arc background */}
|
||||
<path d={`M ${cx - r} ${cy} A ${r} ${r} 0 0 1 ${cx + r} ${cy}`}
|
||||
fill="none" stroke="var(--bg-4)" strokeWidth="6" strokeLinecap="round" />
|
||||
{/* arc value glow */}
|
||||
<path d={`M ${cx - r} ${cy} A ${r} ${r} 0 0 1 ${cx + r} ${cy}`}
|
||||
fill="none" stroke={color} strokeWidth="8" strokeLinecap="round"
|
||||
strokeDasharray={circ} strokeDashoffset={offset}
|
||||
filter={`url(#glow-${label})`} opacity="0.7" />
|
||||
{/* arc value crisp */}
|
||||
<path d={`M ${cx - r} ${cy} A ${r} ${r} 0 0 1 ${cx + r} ${cy}`}
|
||||
fill="none" stroke={color} strokeWidth="5" strokeLinecap="round"
|
||||
strokeDasharray={circ} strokeDashoffset={offset}
|
||||
style={{ transition: 'stroke-dashoffset .5s cubic-bezier(.3,.6,.3,1)' }} />
|
||||
</svg>
|
||||
<div style={{ marginTop: -10, textAlign: 'center' }}>
|
||||
<div className="mono" style={{ fontSize: size * 0.22, fontWeight: 600, color: 'var(--ink-1)', lineHeight: 1 }}>
|
||||
{value}<span style={{ fontSize: '0.55em', color: 'var(--ink-3)', marginLeft: 2 }}>%</span>
|
||||
</div>
|
||||
{label && <div className="label" style={{ marginTop: 2 }}>{label}</div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
BigRadialGauge — la grande jauge cockpit "santé système"
|
||||
============================================================ */
|
||||
function BigRadialGauge({ value = 87, label = 'score santé · stable' }) {
|
||||
const size = 320;
|
||||
const r = 130;
|
||||
const cx = size / 2;
|
||||
const cy = size / 2 + 30;
|
||||
const circ = Math.PI * r;
|
||||
const offset = circ - (value / 100) * circ;
|
||||
const color = value >= 80 ? 'var(--ok)' : value >= 50 ? 'var(--warn)' : 'var(--err)';
|
||||
return (
|
||||
<div className="gauge-hover" style={{ position: 'relative', width: size, height: size * 0.78 }}>
|
||||
<svg width={size} height={size * 0.85}>
|
||||
<defs>
|
||||
<filter id="biggauge-glow" x="-50%" y="-50%" width="200%" height="200%">
|
||||
<feGaussianBlur stdDeviation="4" />
|
||||
</filter>
|
||||
<linearGradient id="biggauge-grad" x1="0" y1="0" x2="1" y2="0">
|
||||
<stop offset="0" stopColor={color} stopOpacity="0.7"/>
|
||||
<stop offset="1" stopColor={color}/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
{/* tics */}
|
||||
{Array.from({ length: 21 }).map((_, i) => {
|
||||
const a = Math.PI - (i / 20) * Math.PI;
|
||||
const major = i % 5 === 0;
|
||||
const inner = major ? r + 8 : r + 11;
|
||||
const outer = major ? r + 20 : r + 15;
|
||||
return <line key={i}
|
||||
x1={cx + Math.cos(a) * inner} y1={cy - Math.sin(a) * inner}
|
||||
x2={cx + Math.cos(a) * outer} y2={cy - Math.sin(a) * outer}
|
||||
stroke={major ? 'var(--ink-3)' : 'var(--ink-4)'} strokeWidth={major ? 1.5 : 0.8}
|
||||
/>;
|
||||
})}
|
||||
{[0, 50, 100].map(v => {
|
||||
const a = Math.PI - (v / 100) * Math.PI;
|
||||
const x = cx + Math.cos(a) * (r + 32);
|
||||
const y = cy - Math.sin(a) * (r + 32) + 4;
|
||||
return <text key={v} x={x} y={y} textAnchor="middle"
|
||||
fontFamily="JetBrains Mono" fontSize="11"
|
||||
fill="var(--ink-3)">{v}</text>;
|
||||
})}
|
||||
{/* arc bg */}
|
||||
<path d={`M ${cx - r} ${cy} A ${r} ${r} 0 0 1 ${cx + r} ${cy}`}
|
||||
fill="none" stroke="var(--bg-4)" strokeWidth="10" strokeLinecap="round" />
|
||||
{/* arc value glow */}
|
||||
<path d={`M ${cx - r} ${cy} A ${r} ${r} 0 0 1 ${cx + r} ${cy}`}
|
||||
fill="none" stroke={color} strokeWidth="14" strokeLinecap="round"
|
||||
strokeDasharray={circ} strokeDashoffset={offset}
|
||||
filter="url(#biggauge-glow)" opacity="0.55" />
|
||||
{/* arc value */}
|
||||
<path d={`M ${cx - r} ${cy} A ${r} ${r} 0 0 1 ${cx + r} ${cy}`}
|
||||
fill="none" stroke="url(#biggauge-grad)" strokeWidth="9" strokeLinecap="round"
|
||||
strokeDasharray={circ} strokeDashoffset={offset}
|
||||
style={{ transition: 'stroke-dashoffset .8s cubic-bezier(.3,.6,.3,1)' }} />
|
||||
{/* needle */}
|
||||
<line x1={cx} y1={cy}
|
||||
x2={cx + Math.cos(Math.PI - (value / 100) * Math.PI) * (r - 14)}
|
||||
y2={cy - Math.sin(Math.PI - (value / 100) * Math.PI) * (r - 14)}
|
||||
stroke="var(--accent)" strokeWidth="3" strokeLinecap="round"
|
||||
style={{ filter: 'drop-shadow(0 0 4px var(--accent-glow))' }} />
|
||||
<circle cx={cx} cy={cy} r="9" fill="var(--bg-3)" stroke="var(--border-3)" strokeWidth="1.5"/>
|
||||
<circle cx={cx} cy={cy} r="3" fill="var(--accent)" />
|
||||
</svg>
|
||||
<div style={{ position: 'absolute', bottom: 12, left: 0, right: 0, textAlign: 'center' }}>
|
||||
<div className="mono" style={{
|
||||
fontSize: 64, fontWeight: 700, lineHeight: 1,
|
||||
color: 'var(--ink-1)',
|
||||
textShadow: `0 0 20px ${color}33`,
|
||||
}}>{value}</div>
|
||||
<div className="label" style={{ marginTop: 6 }}>{label}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
Popup — modale glassmorphism centrée + bouton fermer
|
||||
============================================================ */
|
||||
function Popup({ open, onClose, title, children, footer, width = 460 }) {
|
||||
if (!open) return null;
|
||||
return (
|
||||
<div style={{
|
||||
position: 'absolute', inset: 0, zIndex: 100,
|
||||
background: 'rgba(0,0,0,0.45)',
|
||||
backdropFilter: 'blur(4px)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
animation: 'fadein .2s ease-out',
|
||||
}} onClick={onClose}>
|
||||
<style>{`
|
||||
@keyframes fadein { from { opacity: 0 } to { opacity: 1 } }
|
||||
@keyframes popin { from { opacity: 0; transform: translateY(8px) scale(.98) } to { opacity: 1; transform: translateY(0) scale(1) } }
|
||||
`}</style>
|
||||
<div className="glass-strong" onClick={e => e.stopPropagation()} style={{
|
||||
width, maxWidth: '90%',
|
||||
borderRadius: 12,
|
||||
boxShadow: 'var(--shadow-3)',
|
||||
animation: 'popin .25s cubic-bezier(.3,.7,.3,1.2)',
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
<div style={{
|
||||
padding: '14px 16px',
|
||||
borderBottom: '1px solid var(--border-1)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||
background: 'var(--bg-3)',
|
||||
}}>
|
||||
<div style={{ fontWeight: 600, fontSize: 15, color: 'var(--ink-1)' }}>{title}</div>
|
||||
<IconButton icon="close" label="Fermer" onClick={onClose} size={28} />
|
||||
</div>
|
||||
<div style={{ padding: 18 }}>{children}</div>
|
||||
{footer && (
|
||||
<div style={{
|
||||
padding: '12px 16px',
|
||||
borderTop: '1px solid var(--border-1)',
|
||||
background: 'var(--bg-2)',
|
||||
display: 'flex', justifyContent: 'flex-end', gap: 8,
|
||||
}}>{footer}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
Button — bouton classique avec variantes
|
||||
============================================================ */
|
||||
function Button({ children, icon, onClick, variant = 'default', size = 'md' }) {
|
||||
const sizes = {
|
||||
sm: { padding: '5px 10px', fontSize: 12, h: 28 },
|
||||
md: { padding: '7px 14px', fontSize: 13, h: 34 },
|
||||
lg: { padding: '10px 18px', fontSize: 14, h: 40 },
|
||||
}[size];
|
||||
const variants = {
|
||||
default: { bg: 'var(--bg-3)', fg: 'var(--ink-1)', bd: 'var(--border-2)' },
|
||||
primary: { bg: 'var(--accent)', fg: 'var(--bg-1)', bd: 'var(--accent-soft)' },
|
||||
ghost: { bg: 'transparent', fg: 'var(--ink-2)', bd: 'var(--border-2)' },
|
||||
danger: { bg: 'var(--bg-3)', fg: 'var(--err)', bd: 'var(--err)' },
|
||||
}[variant];
|
||||
return (
|
||||
<button onClick={onClick} className="interactive" style={{
|
||||
height: sizes.h,
|
||||
padding: sizes.padding,
|
||||
background: variants.bg,
|
||||
color: variants.fg,
|
||||
border: `1px solid ${variants.bd}`,
|
||||
borderRadius: 8,
|
||||
display: 'inline-flex', alignItems: 'center', gap: 8,
|
||||
fontFamily: 'inherit', fontSize: sizes.fontSize, fontWeight: 500,
|
||||
cursor: 'pointer',
|
||||
boxShadow: variant === 'primary' ? '0 2px 8px var(--accent-glow)' : 'var(--shadow-1)',
|
||||
}}>
|
||||
{icon && <Icon name={icon} size={14} />}
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
TreeNav — arbre dépliable avec icône en tête (style B)
|
||||
============================================================ */
|
||||
function TreeNav({ groups, activeId, onSelect }) {
|
||||
const [open, setOpen] = useState(() =>
|
||||
Object.fromEntries(groups.map(g => [g.id, g.open !== false]))
|
||||
);
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
{groups.map(g => (
|
||||
<div key={g.id}>
|
||||
<div className="interactive" onClick={() => setOpen({ ...open, [g.id]: !open[g.id] })}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8,
|
||||
padding: '7px 8px', borderRadius: 6,
|
||||
color: 'var(--ink-2)',
|
||||
background: 'transparent',
|
||||
border: '1px solid transparent',
|
||||
cursor: 'pointer',
|
||||
}}>
|
||||
<Icon name="chevR" size={12} style={{
|
||||
transform: open[g.id] ? 'rotate(90deg)' : 'rotate(0)',
|
||||
transition: 'transform .15s',
|
||||
color: 'var(--ink-3)',
|
||||
}} />
|
||||
<Icon name={g.icon || 'folder'} size={15} style={{ color: 'var(--accent)' }} />
|
||||
<span style={{ flex: 1, fontSize: 13, fontWeight: 500 }}>{g.label}</span>
|
||||
{g.count != null && (
|
||||
<span className="mono" style={{ fontSize: 10, color: 'var(--ink-3)' }}>
|
||||
{g.count}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{open[g.id] && (
|
||||
<div style={{ marginLeft: 18, marginTop: 2, display: 'flex', flexDirection: 'column', gap: 1, paddingLeft: 8, borderLeft: '1px dashed var(--border-1)' }}>
|
||||
{g.children.map(c => {
|
||||
const active = c.id === activeId;
|
||||
return (
|
||||
<div key={c.id} className="interactive" onClick={() => onSelect && onSelect(c.id)}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8,
|
||||
padding: '6px 10px', borderRadius: 6,
|
||||
background: active ? 'var(--accent-tint)' : 'transparent',
|
||||
color: active ? 'var(--ink-1)' : 'var(--ink-2)',
|
||||
borderLeft: active ? '2px solid var(--accent)' : '2px solid transparent',
|
||||
marginLeft: active ? 0 : 2,
|
||||
fontSize: 12.5,
|
||||
}}>
|
||||
<StatusLed status={c.status} size={8} pulse={c.status === 'err'} />
|
||||
<span className="mono" style={{ fontSize: 11.5, flex: 1 }}>{c.label}</span>
|
||||
{c.meta && <span className="mono" style={{ fontSize: 10, color: 'var(--ink-3)' }}>{c.meta}</span>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
Sparkline pour les KPI
|
||||
============================================================ */
|
||||
function Sparkline({ points = [], color = 'var(--accent)', h = 28 }) {
|
||||
const w = 100;
|
||||
const max = Math.max(...points);
|
||||
const min = Math.min(...points);
|
||||
const range = max - min || 1;
|
||||
const step = w / (points.length - 1);
|
||||
const path = points.map((p, i) =>
|
||||
`${i === 0 ? 'M' : 'L'} ${(i * step).toFixed(1)} ${(h - 2 - ((p - min) / range) * (h - 4)).toFixed(1)}`
|
||||
).join(' ');
|
||||
const area = path + ` L ${w} ${h} L 0 ${h} Z`;
|
||||
return (
|
||||
<svg viewBox={`0 0 ${w} ${h}`} preserveAspectRatio="none" style={{ width: '100%', height: h }}>
|
||||
<path d={area} fill={color} opacity="0.12" />
|
||||
<path d={path} fill="none" stroke={color} strokeWidth="1.5" strokeLinejoin="round" strokeLinecap="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
LineChart — grand graph multi-séries
|
||||
============================================================ */
|
||||
function LineChart({ series, h = 200, labels }) {
|
||||
const w = 600;
|
||||
const padding = { l: 36, r: 12, t: 12, b: 24 };
|
||||
const innerW = w - padding.l - padding.r;
|
||||
const innerH = h - padding.t - padding.b;
|
||||
const all = series.flatMap(s => s.points);
|
||||
const max = Math.max(...all) * 1.1;
|
||||
const min = 0;
|
||||
const range = max - min;
|
||||
const ptsCount = series[0].points.length;
|
||||
const step = innerW / (ptsCount - 1);
|
||||
return (
|
||||
<svg viewBox={`0 0 ${w} ${h}`} preserveAspectRatio="none" style={{ width: '100%', height: h }}>
|
||||
{/* grid horizontal */}
|
||||
{[0, 0.25, 0.5, 0.75, 1].map(p => {
|
||||
const y = padding.t + innerH * p;
|
||||
const v = Math.round(max - range * p);
|
||||
return (
|
||||
<g key={p}>
|
||||
<line x1={padding.l} x2={w - padding.r} y1={y} y2={y}
|
||||
stroke="var(--border-1)" strokeWidth="1" strokeDasharray="3 5" />
|
||||
<text x={padding.l - 6} y={y + 3} textAnchor="end"
|
||||
fontFamily="JetBrains Mono" fontSize="9" fill="var(--ink-3)">{v}</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
{/* labels x */}
|
||||
{labels && labels.map((lb, i) => (
|
||||
i % Math.ceil(labels.length / 8) === 0 && (
|
||||
<text key={i} x={padding.l + i * step} y={h - 6} textAnchor="middle"
|
||||
fontFamily="JetBrains Mono" fontSize="9" fill="var(--ink-3)">{lb}</text>
|
||||
)
|
||||
))}
|
||||
{/* séries */}
|
||||
{series.map((s, si) => {
|
||||
const path = s.points.map((p, i) =>
|
||||
`${i === 0 ? 'M' : 'L'} ${(padding.l + i * step).toFixed(1)} ${(padding.t + innerH - ((p - min) / range) * innerH).toFixed(1)}`
|
||||
).join(' ');
|
||||
const area = path + ` L ${padding.l + (ptsCount - 1) * step} ${padding.t + innerH} L ${padding.l} ${padding.t + innerH} Z`;
|
||||
return (
|
||||
<g key={si}>
|
||||
<path d={area} fill={s.color} opacity="0.12" />
|
||||
<path d={path} fill="none" stroke={s.color} strokeWidth="1.8"
|
||||
strokeLinejoin="round" strokeLinecap="round" />
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/* Expose */
|
||||
Object.assign(window, {
|
||||
Icon, Tooltip, IconButton, Toggle, StatusLed,
|
||||
BatteryGauge, RadialGauge, BigRadialGauge,
|
||||
Popup, Button, TreeNav, Sparkline, LineChart,
|
||||
});
|
||||
|
||||
/* Effets hover sur les jauges (sans effet au clic) */
|
||||
(function injectGaugeHoverStyles() {
|
||||
if (document.getElementById('gauge-hover-styles')) return;
|
||||
const s = document.createElement('style');
|
||||
s.id = 'gauge-hover-styles';
|
||||
s.textContent = `
|
||||
.bg-hover:hover .bg-bar {
|
||||
border-color: color-mix(in oklch, var(--accent) 60%, var(--border-3));
|
||||
}
|
||||
.bg-hover:hover .bg-fill {
|
||||
box-shadow: 0 0 14px var(--bg-glow, var(--accent-glow));
|
||||
filter: brightness(1.15);
|
||||
}
|
||||
.gauge-hover { transition: filter .2s; }
|
||||
.gauge-hover:hover { filter: drop-shadow(0 0 8px var(--accent-glow)) brightness(1.08); }
|
||||
`;
|
||||
document.head.appendChild(s);
|
||||
})();
|
||||
Reference in New Issue
Block a user