Files
home_hub/design_system/package-smartphone/components/mobile-forms.jsx
T
gilles 4518ed8311 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>
2026-05-30 08:53:36 +02:00

386 lines
16 KiB
React

/* ============================================================
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 });