diff --git a/design_system/package-smartphone/README.md b/design_system/package-smartphone/README.md new file mode 100644 index 0000000..526057a --- /dev/null +++ b/design_system/package-smartphone/README.md @@ -0,0 +1,253 @@ +# mon design system — Package Smartphone (iOS / Android) + +> Adaptation mobile complète de mon design system Gruvbox seventies. +> **45+ composants nommés**, optimisés tactile (hit targets ≥ 44px), animations fluides, +> dark + light, gestes nommés et testables, contrôle complet du clavier virtuel. +> +> Version 1.0 · iOS & Android + +--- + +## 🚀 Démarrage rapide + +```html + + + + + + + + + + + +
+ + + + + + + + + + + + +``` + +--- + +## 📂 Contenu du package + +``` +package-smartphone/ +├── README.md ← Ce fichier (guide humain) +├── consigne_mobile.md ← Brief pour agents IA +├── tokens/ +│ ├── tokens.css ← Variables CSS web (dark + light) +│ ├── tokens.gnome.css ← Pour apps GTK +│ └── tokens.json ← Format générique +├── components/ +│ ├── ui-kit.jsx ← Base (Button, Icon, Popup, gauges…) +│ ├── mobile-kit.jsx ← StatusBar, NavBar, TabBar, ActionCard… +│ ├── mobile-sheets.jsx ← BottomSheet, ActionSheet, AlertDialog, Toast, FAB, PullToRefresh +│ ├── mobile-gestures.jsx ← useGesture, GestureZone (9 gestes nommés) +│ ├── mobile-swipeable.jsx ← SwipeableRow (swipe-to-reveal actions) +│ ├── mobile-forms.jsx ← TextInput, DateInput, Dropdown, Checkbox, Radio, MediaInsert… +│ └── mobile-apps.jsx ← Avatar+menu, Onboarding, Chat, Calendar, Maps, Scanner, Caméra, Files +└── examples/ + ├── exemple-mobile.html ← Vue d'ensemble + gestes interactifs + ├── exemple-mobile-saisie.html ← Login, profil, formulaire complet, clavier virtuel + └── exemple-mobile-apps.html ← 10 écrans : onboarding, chat, calendrier, maps, scanner, caméra, fichiers, settings +``` + +Tous les exemples ont leurs composants copiés à côté pour fonctionner sans navigation `../`. Ouvre n'importe lequel directement dans le navigateur. + +--- + +## 🧩 Tous les composants nommés + +### Structure d'écran +- **`StatusBar`** — barre de statut iOS (heure, signal, batterie) +- **`NavBar`** — barre titre, mode compact ou large, bouton retour, actions à droite +- **`TabBar`** — barre d'onglets en bas (3-5 sections) + +### Boutons & contrôles +- **`PrimaryButton`** — gros bouton plein largeur (primary, ghost, danger) +- **`IconButton`** — bouton icône seul avec tooltip +- **`Toggle`** — switch on/off +- **`SegmentedControl`** — sélecteur 2-4 options exclusives +- **`Button`** — bouton générique (depuis ui-kit) + +### Listes +- **`ListSection / ListRow`** — liste de réglages iOS-style +- **`ActionCard`** — grosse tuile tactile pour grille d'apps +- **`SwipeableRow`** — ligne révélant des actions au swipe (Mail/Things) +- **`FileExplorer`** — liste de fichiers avec icônes par type + actions swipe + +### Saisie +- **`FormField`** — wrapper avec label + hint + erreur +- **`TextInput`** — champ texte avec contrôle complet du clavier virtuel +- **`DateInput`** — picker date/heure/datetime natif mobile +- **`Dropdown`** — select avec sélecteur natif +- **`CheckboxItem`** — case à cocher avec label + description +- **`RadioGroup`** — options exclusives empilées +- **`MediaInsert`** — grille pour ajouter photo/image/vidéo/audio/fichier/GPS +- **`SearchBar`** — recherche tactile avec icône loupe +- **`FilterChips`** — chips horizontaux multi-sélection + +### Fenêtres / Dialogues +- **`BottomSheet`** — feuille modale qui monte du bas (swipe-down pour fermer) +- **`ActionSheet`** — menu d'actions iOS (2-6 choix) +- **`AlertDialog`** — alerte centrée bloquante (confirmation destructive) +- **`Toast`** — notification éphémère 2.5s +- **`Popup`** — modale glassmorphism (depuis ui-kit) + +### Identité utilisateur +- **`Avatar`** — bouton rond avec initiales (haut-droite de chaque écran) +- **`AvatarMenu`** — popup descendant : Profil / Paramètres / Aide / Déconnexion +- **`AvatarLogo`** — gros logo carré arrondi (login, profil) +- **`BiometricButton`** — Face ID / Touch ID + +### Patterns d'app +- **`OnboardingSlider`** — slides + dots + suivant/passer +- **`ChatBubble`** — bulle message (envoyé/reçu, statut ✓ ✓✓) +- **`ChatComposer`** — barre d'envoi avec joindre + audio + send +- **`CalendarMonth`** — vue mois avec évènements +- **`MapView`** — carte avec pins colorés +- **`QrScannerView`** — viseur scanner avec ligne animée +- **`CameraView`** — viseur photo avec règle des tiers + +### Gestes +- **`useGesture`** — hook qui détecte 9 gestes nommés +- **`GestureZone`** — zone interactive qui affiche le geste détecté + +### Datavis (mobile-friendly) +- **`RadialGauge`**, **`BatteryGauge`** (compact ou standard), **`BigRadialGauge`**, **`Sparkline`**, **`LineChart`**, **`StatusLed`** + +--- + +## ✋ Gestes nommés + +9 gestes détectables par `useGesture()` : + +| Nom | Geste | Usage typique | +|-------------|---------------------------------------|---------------------------------------| +| `Tap` | Pression rapide | Action principale (équiv. clic) | +| `DoubleTap` | Deux Tap rapprochés (< 300 ms) | Zoomer, liker | +| `LongPress` | Pression maintenue ≥ 500 ms | Menu contextuel, sélection | +| `SwipeLeft` | Glisser vers la gauche | Écran suivant, supprimer une ligne | +| `SwipeRight`| Glisser vers la droite | Écran précédent, archiver | +| `SwipeUp` | Glisser vers le haut | Plus de détails, fermer popup | +| `SwipeDown` | Glisser vers le bas | Rafraîchir, fermer BottomSheet | +| `Pan` | Glisser en continu | Déplacer un élément, scroll horizontal| +| `Pinch` | Écarter / rapprocher 2 doigts | Zoomer carte ou image | + +--- + +## ⌨️ Clavier virtuel — paramètres + +`TextInput` accepte 4 props clés pour contrôler le clavier mobile : + +### `keyboard` (inputmode) — quel clavier afficher +- `text` — clavier standard +- `numeric` — pavé 0-9 (codes, OTP) +- `decimal` — pavé + virgule (prix, mesures) +- `tel` — pavé téléphone +- `email` — clavier texte + @ et . directs +- `url` — clavier texte + / et .com +- `search` — clavier standard, touche Entrée = "Rechercher" +- `none` — aucun clavier (picker custom) + +### `autocomplete` — aide à la saisie système +- `email` · `tel` · `name` · `given-name` · `family-name` +- `address-line1` · `postal-code` · `country` +- `current-password` · `new-password` +- `one-time-code` (auto-lecture SMS sur iOS/Android !) +- `off` + +### `enterHint` — texte de la touche Entrée +- `send` · `search` · `go` · `done` · `next` · `previous` + +### `autocapitalize` — majuscules auto +- `sentences` · `words` · `characters` · `off` + +### Combinaisons usuelles + +| Cas | Réglages | +|--------------------|-------------------------------------------------------------------| +| Email | `keyboard="email" autocomplete="email" autocapitalize="off"` | +| Mot de passe | `type="password" autocomplete="current-password"` (ou `new-password`) | +| Code OTP SMS | `keyboard="numeric" autocomplete="one-time-code" maxLength={6}` | +| Téléphone | `keyboard="tel" autocomplete="tel"` | +| Recherche | `keyboard="search" enterHint="search"` | +| Prix / mesure | `keyboard="decimal"` | +| Adresse postale | `autocomplete="address-line1"`, puis `postal-code`, `country` | +| Texte libre | `autocapitalize="sentences" spellCheck={true}` | + +--- + +## 🪟 Types de fenêtres mobile + +| Type | Quand l'utiliser | Geste pour fermer | +|----------------|------------------------------------------------------------------|----------------------| +| `BottomSheet` | Action contextuelle, formulaire court, choix dans une liste | SwipeDown ↓ | +| `ActionSheet` | Menu d'actions (2-6 choix) sur un élément | Tap "Annuler" ou hors| +| `AlertDialog` | Confirmation destructive (suppression, déconnexion) | Volontairement bloquant| +| `Toast` | Feedback succès/erreur après une action | Auto 2.5s | +| `Popup` | Modale centrée plus large (form de config avancé) | ✕ ou clic extérieur | + +--- + +## 📱 3 pages d'exemples (à ouvrir) + +1. **`examples/exemple-mobile.html`** — vue d'ensemble, gestes interactifs, premières patterns +2. **`examples/exemple-mobile-saisie.html`** — login, profil, formulaire complet, clavier virtuel +3. **`examples/exemple-mobile-apps.html`** — 10 patterns d'app : onboarding, chat, calendrier, maps, scanner QR, caméra, fichiers, settings, avatar menu + +Chaque page affiche un smartphone à gauche (basculable iOS/Android, sombre/clair) et la doc commentée à droite avec mini-visuels SVG sous chaque composant et écran. + +--- + +## ⚙️ Paramétrage + +Tout fonctionne avec les **mêmes tokens CSS** que le package desktop. Pour changer une couleur, édite `tokens/tokens.css` : + +```css +:root[data-theme="dark"] { + --accent: #fe8019; /* Orange Gruvbox */ + --bg-1: #2a231d; /* Fond app */ + --ok: #4dbb26; + --warn: #fabd2f; + --err: #fb4934; + --blue: #3db0d1; + --purple: #c882c8; +} +``` + +Bascule de thème : +```js +document.documentElement.dataset.theme = 'light'; // ou 'dark' +``` + +--- + +## ✅ Checklist d'intégration + +- [ ] Polices chargées (Inter, JetBrains Mono, Share Tech Mono) +- [ ] Font Awesome 6 chargé +- [ ] `tokens/tokens.css` chargé +- [ ] `data-theme="dark"` (ou "light") sur `` +- [ ] React 18 + Babel chargés +- [ ] `ui-kit.jsx` chargé en premier +- [ ] Modules mobile chargés dans l'ordre : kit → sheets → gestures → swipeable → forms → apps + +--- + +## 🤖 Pour les agents IA + +Lire `consigne_mobile.md` pour les règles d'utilisation et conventions. diff --git a/design_system/package-smartphone/components/mobile-apps.jsx b/design_system/package-smartphone/components/mobile-apps.jsx new file mode 100644 index 0000000..7428f02 --- /dev/null +++ b/design_system/package-smartphone/components/mobile-apps.jsx @@ -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 ( + + ); +} + +/* ============================================================ + 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 ( +
+ +
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', + }}> +
+ +
+
{name}
+ {email &&
{email}
} +
+
+ {items.map((it, i) => ( + + ))} +
+
+ ); +} + +/* ============================================================ + 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 ( +
+
+ +
+
+
+ + +
+
{slides[i].title}
+
{slides[i].desc}
+
+
+
+ {slides.map((_, j) => ( + 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', + }} /> + ))} +
+ isLast ? onFinish() : setI(i + 1)}> + {isLast ? 'Commencer' : 'Suivant'} + +
+
+ ); +} + +/* ============================================================ + ChatBubble — bulle de message (envoyé/reçu) + Nom système : ChatBubble + ============================================================ */ +function ChatBubble({ text, time, me, status }) { + return ( +
+
+
{text}
+
+ {time} + {me && status === 'sent' && } + {me && status === 'read' && ✓✓} +
+
+
+ ); +} + +/* ============================================================ + ChatComposer — barre d'envoi en bas (input + + + send) + Nom système : ChatComposer + ============================================================ */ +function ChatComposer({ onSend }) { + const [v, setV] = uA(''); + return ( +
+ +
+ 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, + }} /> +
+ {v ? ( + + ) : ( + + )} +
+ ); +} + +/* ============================================================ + 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 ( +
+
+ +
{monthName}
+ +
+
+ {['L', 'M', 'M', 'J', 'V', 'S', 'D'].map((d, i) => ( +
{d}
+ ))} + {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 ( + + ); + })} +
+
+ ); +} + +/* ============================================================ + MapView — placeholder visuel d'une carte avec pins + Nom système : MapView + ============================================================ */ +function MapView({ pins = [] }) { + return ( +
+ {/* fond carte stylisé */} + + + + + + + + {/* routes */} + + + + {/* zones */} + + + + {/* fleuve */} + + + {/* pins */} + {pins.map((p, i) => ( +
+
+ +
+ {p.label && ( +
{p.label}
+ )} +
+ ))} +
+ ); +} + +/* ============================================================ + 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 ( +
+ {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 ( + + ); + })} +
+ ); +} + +/* ============================================================ + QrScannerView — viseur scanner code-barres / QR + Nom système : QrScannerView + ============================================================ */ +function QrScannerView({ onCapture }) { + return ( +
+ {/* fake camera feed = grain animé */} +
+ {/* visée centrale */} +
+ {/* 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) => ( +
+ ))} + {/* ligne scan animée */} +
+ +
+ {/* overlay assombri hors visée */} +
+ {/* texte */} +
Pointe vers un QR code ou code-barres
+ {/* boutons bas */} +
+ + + +
+
+ ); +} + +/* ============================================================ + CameraView — viseur appareil photo avec shutter rond + Nom système : CameraView + ============================================================ */ +function CameraView({ onShoot }) { + return ( +
+ {/* fake scene */} +
+ {/* règle des tiers */} +
+ {[33.33, 66.66].map((p) => ( + +
+
+ + ))} +
+ {/* top bar */} +
+ {[ + { icon: 'moon', label: 'Flash' }, + { icon: 'clock', label: 'Minuteur' }, + { icon: 'grid', label: 'Grille' }, + ].map((b) => ( + + ))} +
+ {/* mode chips */} +
+ Vidéo + Photo + Portrait +
+ {/* bottom controls */} +
+
+
+
+ ); +} + +/* ============================================================ + 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 ( +
+ {items.map((it) => ( + 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) }, + ]}> +
+ + + +
+
{it.name}
+
+ {it.date || ''} {it.size != null && `· ${sizeFmt(it.size)}`} +
+
+ {it.type === 'folder' && } +
+
+ ))} +
+ ); +} + +Object.assign(window, { + Avatar, AvatarMenu, + OnboardingSlider, + ChatBubble, ChatComposer, + CalendarMonth, + MapView, + FilterChips, + QrScannerView, CameraView, + FileExplorer, +}); diff --git a/design_system/package-smartphone/components/mobile-forms.jsx b/design_system/package-smartphone/components/mobile-forms.jsx new file mode 100644 index 0000000..50dc442 --- /dev/null +++ b/design_system/package-smartphone/components/mobile-forms.jsx @@ -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 ( +
+ {label && ( + + )} + {children} + {(error || hint) && ( +
{error || hint}
+ )} +
+ ); +} + +/* ============================================================ + 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 ( +
+ {icon && } + + {trailing} +
+ ); +} + +/* ============================================================ + 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 ( +
+ + 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', + }} + /> +
+ ); +} + +/* ============================================================ + Dropdown — select natif stylisé + Nom système : Dropdown + ============================================================ */ +function Dropdown({ value, onChange, options, placeholder = 'Choisir…' }) { + return ( +
+ + +
+ ); +} + +/* ============================================================ + 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 ( + + ); +} + +/* ============================================================ + RadioGroup — groupe d'options exclusives + Nom système : RadioGroup + ============================================================ */ +function RadioGroup({ value, onChange, options }) { + return ( +
+ {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 ( + + ); + })} +
+ ); +} + +/* ============================================================ + 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 + 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 ( +
+ {items.map((it) => ( + + ))} +
+ ); +} + +/* ============================================================ + AvatarLogo — gros logo rond pour écran de connexion + Nom système : AvatarLogo + ============================================================ */ +function AvatarLogo({ icon = 'server', size = 80, glow = true }) { + return ( +
+ +
+ ); +} + +/* ============================================================ + 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 ( + + ); +} + +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 }); diff --git a/design_system/package-smartphone/components/mobile-gestures.jsx b/design_system/package-smartphone/components/mobile-gestures.jsx new file mode 100644 index 0000000..2d5b613 --- /dev/null +++ b/design_system/package-smartphone/components/mobile-gestures.jsx @@ -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 ( +
+ {label && ( +
{label}
+ )} +
+ {/* indicateur central */} +
+ {last ? ( +
+ {last.name} + {last.data && ( +
+ {Object.entries(last.data).map(([k, v]) => `${k}: ${v}`).join(' · ')} +
+ )} +
+ ) : ( + essaie un geste ici + )} + +
+ {/* trail visuel pendant le pan */} + {trail && ( +
+ )} +
+ {/* Journal */} + {log.length > 0 && ( +
+
+ journal + {log.length} dernier{log.length > 1 ? 's' : ''} +
+ {log.map((l, i) => ( +
+ {l.t}{' '} + {l.name} +
+ ))} +
+ )} +
+ ); +} + +/* 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 }); diff --git a/design_system/package-smartphone/components/mobile-kit.jsx b/design_system/package-smartphone/components/mobile-kit.jsx new file mode 100644 index 0000000..f0428b8 --- /dev/null +++ b/design_system/package-smartphone/components/mobile-kit.jsx @@ -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 ( +
+ {time} + + {/* signal bars */} + + {[1, 2, 3, 4].map((b) => ( + + ))} + + + {/* battery */} + + + + + +
+ ); +} + +/* ============================================================ + 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 ( +
+
+ {onBack && ( + + )} +
+ {!large && ( +
+ {title} +
+ )} +
+ {right &&
{right}
} +
+ {large && ( +
+
{title}
+ {subtitle &&
{subtitle}
} +
+ )} +
+ ); +} + +/* ============================================================ + 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 ( +
+ {items.map((it) => { + const isActive = active === it.id; + return ( + + ); + })} +
+ ); +} + +/* ============================================================ + 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 ( + e.currentTarget.style.background = 'var(--bg-3)' : undefined} + onTouchEnd={isInteractive ? (e) => e.currentTarget.style.background = 'transparent' : undefined}> + {icon && ( + + + + )} + {label} + {value && {value}} + {right === undefined && onClick && } + {right} + + ); +} + +/* ============================================================ + ListSection — groupe de ListRow avec titre + Nom système : ListSection + ============================================================ */ +function ListSection({ title, hint, children }) { + return ( +
+ {title && ( +
{title}
+ )} +
{children}
+ {hint && ( +
{hint}
+ )} +
+ ); +} + +/* ============================================================ + 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 ( + + ); +} + +/* ============================================================ + 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 ( + + ); +} + +/* ============================================================ + SegmentedControl — sélecteur segmenté iOS-style + Nom système : SegmentedControl + Usage : 2-4 options exclusives, jamais plus. + ============================================================ */ +function SegmentedControl({ value, onChange, options }) { + return ( +
+ {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 ( + + ); + })} +
+ ); +} + +/* ============================================================ + SearchBar — champ de recherche mobile + Nom système : SearchBar + ============================================================ */ +function SearchBar({ value, onChange, placeholder = 'Rechercher' }) { + return ( +
+ + 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 && ( + + )} +
+ ); +} + +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); +})(); diff --git a/design_system/package-smartphone/components/mobile-sheets.jsx b/design_system/package-smartphone/components/mobile-sheets.jsx new file mode 100644 index 0000000..3abe582 --- /dev/null +++ b/design_system/package-smartphone/components/mobile-sheets.jsx @@ -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 ( +
+
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 */} +
+
+
+ {title && ( +
+
{title}
+ +
+ )} +
{children}
+ {footer && ( +
{footer}
+ )} +
+
+ ); +} + +/* ============================================================ + 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 ( +
+ +
e.stopPropagation()} style={{ + width: '100%', + display: 'flex', flexDirection: 'column', gap: 8, + animation: 'as-slide .25s cubic-bezier(.3,.7,.3,1.2)', + }}> +
+ {title && ( +
{title}
+ )} + {actions.map((a, i) => ( + + ))} +
+ +
+
+ ); +} + +/* ============================================================ + 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 ( +
+
+ +
+ {icon && ( +
+ +
+ )} +
{title}
+ {message &&
{message}
} +
+
+ {actions.map((a, i) => ( + + ))} +
+
+
+ ); +} + +/* ============================================================ + 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 ( +
+ + + {message} +
+ ); +} + +/* ============================================================ + 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 ( + + ); +} + +/* ============================================================ + 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 ( +
+ {/* indicateur */} +
+
+ + +
+
+
{children}
+
+ ); +} + +Object.assign(window, { + BottomSheet, ActionSheet, AlertDialog, Toast, FAB, PullToRefresh, +}); diff --git a/design_system/package-smartphone/components/mobile-swipeable.jsx b/design_system/package-smartphone/components/mobile-swipeable.jsx new file mode 100644 index 0000000..6037d1f --- /dev/null +++ b/design_system/package-smartphone/components/mobile-swipeable.jsx @@ -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 ( +
+ {/* Actions à GAUCHE (révélées par swipe droit) */} + {rightActions.length > 0 && ( +
+ {rightActions.map((a, i) => ( + + ))} +
+ )} + {/* Actions à DROITE (révélées par swipe gauche) */} + {leftActions.length > 0 && ( +
+ {leftActions.map((a, i) => ( + + ))} +
+ )} + {/* Ligne déplaçable */} +
+ {children} +
+
+ ); +} + +Object.assign(window, { SwipeableRow }); diff --git a/design_system/package-smartphone/components/ui-kit.jsx b/design_system/package-smartphone/components/ui-kit.jsx new file mode 100644 index 0000000..92f9e76 --- /dev/null +++ b/design_system/package-smartphone/components/ui-kit.jsx @@ -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 . 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 ( +