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,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 });
|
||||
Reference in New Issue
Block a user