feat(ux): user-select global, swipe-gauche édition, clavier numérique, versionnage v0.4.0

- user-select:none global (index.css) + reset sur input/textarea/select
- ItemRow: swipe gauche → édition (fond bleu), suppression long press,
  bouton ✕ toujours visible sur mobile
- SwipeableRow: prop onSwipeLeft, révèle rightContent entre seuil/2 et seuil,
  déclenche onSwipeLeft au seuil complet
- TodosPage: onSwipeLeft → édition (remplace double-tap)
- inputMode=decimal sur tous les champs quantité et prix
- formatQty: affiche "2" au lieu de "2.000"
- Versionnage: __APP_VERSION__ injecté par Vite depuis package.json v0.4.0
- HomePage: version affichée à côté du titre (v0.4.0)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-25 07:38:23 +02:00
parent f86dd01d95
commit 19c686b4be
10 changed files with 59 additions and 68 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "homehub-frontend", "name": "homehub-frontend",
"private": true, "private": true,
"version": "0.1.0", "version": "0.4.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
@@ -228,11 +228,13 @@ export default function CatalogueModal({ stores, onClose }: CatalogueModalProps)
/> />
<input <input
style={inputStyle} placeholder="Prix (€)" style={inputStyle} placeholder="Prix (€)"
inputMode="decimal"
value={form.price ?? ''} value={form.price ?? ''}
onChange={e => setForm(f => ({ ...f, price: e.target.value }))} onChange={e => setForm(f => ({ ...f, price: e.target.value }))}
/> />
<input <input
style={inputStyle} placeholder="Qté/unité" style={inputStyle} placeholder="Qté/unité"
inputMode="decimal"
value={form.quantity_per_unit ?? ''} value={form.quantity_per_unit ?? ''}
onChange={e => setForm(f => ({ ...f, quantity_per_unit: e.target.value }))} onChange={e => setForm(f => ({ ...f, quantity_per_unit: e.target.value }))}
/> />
+21 -42
View File
@@ -10,57 +10,38 @@ interface ItemRowProps {
} }
const SWIPE_THRESHOLD = 80 const SWIPE_THRESHOLD = 80
const LONG_PRESS_MS = 500
function formatQty(qty: string): string {
const n = parseFloat(qty)
if (isNaN(n)) return qty
return n % 1 === 0 ? String(Math.round(n)) : n.toString()
}
export default function ItemRow({ item, onCheck, onDelete, onEdit, storeMode = false }: ItemRowProps) { export default function ItemRow({ item, onCheck, onDelete, onEdit, storeMode = false }: ItemRowProps) {
const [offsetX, setOffsetX] = useState(0) const [offsetX, setOffsetX] = useState(0)
const [isDragging, setIsDragging] = useState(false) const [isDragging, setIsDragging] = useState(false)
const startX = useRef<number | null>(null) const startX = useRef<number | null>(null)
const longPressTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
const didLongPress = useRef(false)
function clearLongPress() {
if (longPressTimer.current) {
clearTimeout(longPressTimer.current)
longPressTimer.current = null
}
}
function onTouchStart(e: React.TouchEvent) { function onTouchStart(e: React.TouchEvent) {
startX.current = e.touches[0].clientX startX.current = e.touches[0].clientX
setIsDragging(true) setIsDragging(true)
didLongPress.current = false
if (onEdit) {
longPressTimer.current = setTimeout(() => {
didLongPress.current = true
onEdit()
}, LONG_PRESS_MS)
}
} }
function onTouchMove(e: React.TouchEvent) { function onTouchMove(e: React.TouchEvent) {
if (startX.current === null) return if (startX.current === null) return
const dx = e.touches[0].clientX - startX.current const dx = e.touches[0].clientX - startX.current
if (Math.abs(dx) > 8) clearLongPress()
if (!storeMode) setOffsetX(Math.max(Math.min(dx, 0), -120)) if (!storeMode) setOffsetX(Math.max(Math.min(dx, 0), -120))
} }
function onTouchEnd() { function onTouchEnd() {
clearLongPress() if (!storeMode && offsetX < -SWIPE_THRESHOLD && onEdit) {
if (!storeMode && offsetX < -SWIPE_THRESHOLD) { onEdit()
onDelete()
} }
setOffsetX(0) setOffsetX(0)
setIsDragging(false) setIsDragging(false)
startX.current = null startX.current = null
} }
function handleClick() {
if (didLongPress.current) return
onCheck()
}
const minHeight = storeMode ? 64 : 52 const minHeight = storeMode ? 64 : 52
return ( return (
@@ -69,11 +50,11 @@ export default function ItemRow({ item, onCheck, onDelete, onEdit, storeMode = f
<div style={{ <div style={{
position: 'absolute', right: 0, top: 0, bottom: 0, position: 'absolute', right: 0, top: 0, bottom: 0,
width: 120, display: 'flex', alignItems: 'center', justifyContent: 'center', width: 120, display: 'flex', alignItems: 'center', justifyContent: 'center',
background: 'var(--err)', background: 'var(--info)',
opacity: offsetX < -20 ? 1 : 0, opacity: offsetX < -20 ? 1 : 0,
transition: 'opacity 0.15s', transition: 'opacity 0.15s',
}}> }}>
<span style={{ color: '#fff', fontSize: 20 }}></span> <span style={{ color: '#fff', fontSize: 20 }}></span>
</div> </div>
)} )}
@@ -81,7 +62,7 @@ export default function ItemRow({ item, onCheck, onDelete, onEdit, storeMode = f
onTouchStart={onTouchStart} onTouchStart={onTouchStart}
onTouchMove={onTouchMove} onTouchMove={onTouchMove}
onTouchEnd={onTouchEnd} onTouchEnd={onTouchEnd}
onClick={handleClick} onClick={onCheck}
style={{ style={{
transform: `translateX(${offsetX}px)`, transform: `translateX(${offsetX}px)`,
transition: isDragging ? 'none' : 'transform 0.2s ease', transition: isDragging ? 'none' : 'transform 0.2s ease',
@@ -126,7 +107,7 @@ export default function ItemRow({ item, onCheck, onDelete, onEdit, storeMode = f
</div> </div>
{(item.quantity || item.unit) && ( {(item.quantity || item.unit) && (
<div style={{ color: 'var(--ink-3)', fontSize: 11, marginTop: 2, fontFamily: 'var(--font-mono)' }}> <div style={{ color: 'var(--ink-3)', fontSize: 11, marginTop: 2, fontFamily: 'var(--font-mono)' }}>
{item.quantity}{item.unit ? ` ${item.unit}` : ''} {item.quantity ? formatQty(item.quantity) : ''}{item.unit ? ` ${item.unit}` : ''}
</div> </div>
)} )}
</div> </div>
@@ -153,17 +134,15 @@ export default function ItemRow({ item, onCheck, onDelete, onEdit, storeMode = f
></button> ></button>
</div> </div>
{/* Suppression mode magasin mobile uniquement */} {/* Suppression mobile — visible sur toutes les lignes */}
{storeMode && !item.is_checked && ( <button
<button className="flex lg:hidden"
className="flex lg:hidden" onClick={e => { e.stopPropagation(); onDelete() }}
onClick={e => { e.stopPropagation(); onDelete() }} style={{
style={{ background: 'transparent', border: 'none', color: 'var(--ink-4)',
background: 'transparent', border: 'none', color: 'var(--ink-4)', fontSize: 18, cursor: 'pointer', padding: '4px 8px', minHeight: 44,
fontSize: 18, cursor: 'pointer', padding: '4px 8px', minHeight: 44, }}
}} ></button>
></button>
)}
</div> </div>
</div> </div>
) )
+10 -21
View File
@@ -4,17 +4,15 @@ interface SwipeableRowProps {
children: React.ReactNode children: React.ReactNode
rightContent: React.ReactNode rightContent: React.ReactNode
onSwipeRight?: () => void onSwipeRight?: () => void
onDoubleTap?: () => void onSwipeLeft?: () => void
} }
const THRESHOLD = 80 const THRESHOLD = 80
const DOUBLE_TAP_DELAY = 300
export default function SwipeableRow({ children, rightContent, onSwipeRight, onDoubleTap }: SwipeableRowProps) { export default function SwipeableRow({ children, rightContent, onSwipeRight, onSwipeLeft }: SwipeableRowProps) {
const [offsetX, setOffsetX] = useState(0) const [offsetX, setOffsetX] = useState(0)
const [isDragging, setIsDragging] = useState(false) const [isDragging, setIsDragging] = useState(false)
const startX = useRef<number | null>(null) const startX = useRef<number | null>(null)
const lastTap = useRef<number>(0)
function onTouchStart(e: React.TouchEvent) { function onTouchStart(e: React.TouchEvent) {
startX.current = e.touches[0].clientX startX.current = e.touches[0].clientX
@@ -28,27 +26,14 @@ export default function SwipeableRow({ children, rightContent, onSwipeRight, onD
} }
function onTouchEnd() { function onTouchEnd() {
const finalOffset = offsetX if (offsetX > THRESHOLD && onSwipeRight) onSwipeRight()
if (offsetX < -THRESHOLD && onSwipeLeft) onSwipeLeft()
if (finalOffset > THRESHOLD && onSwipeRight) {
onSwipeRight()
}
// Double-tap : seulement si le doigt n'a pas bougé (tap, pas swipe)
if (Math.abs(finalOffset) < 10 && onDoubleTap) {
const now = Date.now()
if (now - lastTap.current < DOUBLE_TAP_DELAY) {
onDoubleTap()
}
lastTap.current = now
}
setOffsetX(0) setOffsetX(0)
setIsDragging(false) setIsDragging(false)
startX.current = null startX.current = null
} }
const revealActions = offsetX < -(THRESHOLD / 2) const revealActions = offsetX < -(THRESHOLD / 2) && offsetX > -THRESHOLD
return ( return (
<div style={{ position: 'relative', overflow: 'hidden' }}> <div style={{ position: 'relative', overflow: 'hidden' }}>
@@ -76,7 +61,11 @@ export default function SwipeableRow({ children, rightContent, onSwipeRight, onD
style={{ style={{
transform: `translateX(${offsetX}px)`, transform: `translateX(${offsetX}px)`,
transition: isDragging ? 'none' : 'transform 0.2s ease', transition: isDragging ? 'none' : 'transform 0.2s ease',
background: offsetX > THRESHOLD / 2 ? 'var(--ok)' : 'var(--bg-3)', background: offsetX > THRESHOLD / 2
? 'var(--ok)'
: offsetX < -THRESHOLD
? 'var(--info)'
: 'var(--bg-3)',
position: 'relative', position: 'relative',
zIndex: 1, zIndex: 1,
}} }}
+7
View File
@@ -6,6 +6,13 @@
* { * {
box-sizing: border-box; box-sizing: border-box;
-webkit-user-select: none;
user-select: none;
}
input, textarea, select {
-webkit-user-select: text;
user-select: text;
} }
body { body {
+8 -3
View File
@@ -4,9 +4,14 @@ export default function HomePage() {
return ( return (
<div className="p-4"> <div className="p-4">
<div className="glass" style={{ padding: 20, borderRadius: 10, marginBottom: 16 }}> <div className="glass" style={{ padding: 20, borderRadius: 10, marginBottom: 16 }}>
<h1 style={{ color: 'var(--accent)', fontFamily: 'var(--font-mono)', margin: 0 }}> <div style={{ display: 'flex', alignItems: 'baseline', gap: 10 }}>
HomeHub <h1 style={{ color: 'var(--accent)', fontFamily: 'var(--font-mono)', margin: 0 }}>
</h1> HomeHub
</h1>
<span style={{ color: 'var(--ink-4)', fontFamily: 'var(--font-mono)', fontSize: 12 }}>
v{__APP_VERSION__}
</span>
</div>
<p style={{ color: 'var(--ink-2)', marginTop: 8 }}> <p style={{ color: 'var(--ink-2)', marginTop: 8 }}>
Application d&apos;organisation personnelle Application d&apos;organisation personnelle
</p> </p>
+2
View File
@@ -447,6 +447,7 @@ export default function ShoppingPage() {
<input <input
style={inputStyle} style={inputStyle}
placeholder="Quantité" placeholder="Quantité"
inputMode="decimal"
value={editQty} value={editQty}
onChange={e => setEditQty(e.target.value)} onChange={e => setEditQty(e.target.value)}
autoFocus autoFocus
@@ -555,6 +556,7 @@ export default function ShoppingPage() {
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8 }}> <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8 }}>
<input <input
style={inputStyle} placeholder="Quantité" style={inputStyle} placeholder="Quantité"
inputMode="decimal"
value={newItemQty} value={newItemQty}
onChange={e => setNewItemQty(e.target.value)} onChange={e => setNewItemQty(e.target.value)}
/> />
+1 -1
View File
@@ -197,7 +197,7 @@ export default function TodosPage() {
<SwipeableRow <SwipeableRow
key={todo.id} key={todo.id}
onSwipeRight={() => void handleDone(todo.id)} onSwipeRight={() => void handleDone(todo.id)}
onDoubleTap={() => setEditingTodo(todo)} onSwipeLeft={() => setEditingTodo(todo)}
rightContent={ rightContent={
<div style={{ display: 'flex', gap: 4, padding: '0 8px' }}> <div style={{ display: 'flex', gap: 4, padding: '0 8px' }}>
<button <button
+3
View File
@@ -0,0 +1,3 @@
/// <reference types="vite/client" />
declare const __APP_VERSION__: string
+4
View File
@@ -1,8 +1,12 @@
import { defineConfig } from 'vite' import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react' import react from '@vitejs/plugin-react'
import { VitePWA } from 'vite-plugin-pwa' import { VitePWA } from 'vite-plugin-pwa'
import pkg from './package.json'
export default defineConfig({ export default defineConfig({
define: {
__APP_VERSION__: JSON.stringify(pkg.version),
},
plugins: [ plugins: [
react(), react(),
VitePWA({ VitePWA({