Fiche : fermer en glissant vers le bas sur téléphone

La feuille de la fiche (PersonCard) se ferme en glissant sa poignée ou son en-tête vers le bas, comme le menu de la barre. Le geste est factorisé : logique pure dans utils/sheetDrag.js, hook dans hooks/useSheetDrag.js, utilisé aussi par NavMenu (poignée, seuil de 70 px inchangé).

- Le geste part seulement de la poignée et de l'en-tête. Jamais d'un champ de saisie, d'un libellé ni pendant une sélection de texte. Le contenu des onglets défile comme avant.
- La feuille suit le doigt (transform), ne monte jamais au-delà de sa position de repos.
- Fermeture au-delà d'un tiers de la hauteur, ou sur un lâcher rapide vers le bas. Sinon retour court.
- La fermeture passe par le même chemin que la croix (enregistrement automatique compris). Si l'enregistrement est refusé, la fiche revient et affiche son erreur.
- Mode feuille seulement (PHONE_QUERY et largeur de la feuille CSS). Sur ordinateur, rien ne change.
- prefers-reduced-motion respecté.

Tests : logique du geste (zones, direction, seuil, vitesse) en vitest. Recette Playwright iPhone 13 émulé, gestes tactiles CDP : fermeture par la poignée, retour après un petit glisser, lâcher rapide, défilement du contenu sans fermer, pas de geste depuis un champ, enregistrement puis fermeture, ordinateur inchangé, croix.

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
This commit is contained in:
IliesAllaliandClaude Haiku 5.5 committed 2026-10-19 11:06:35 +02:00
1 parent 0d08b0f148
commit 6faaefebee
9 files changed
+336 -33

No files matched your search

+147
View File
@@ -0,0 +1,147 @@
import { useEffect, useRef, useState } from 'react'
import {
PHONE_QUERY,
SHEET_WIDTH_QUERY,
canStartSheetDrag,
shouldCloseSheet,
sheetDragIntent,
velocityBetween,
} from '../utils/sheetDrag'
const wait = (ms) => new Promise((resolve) => { setTimeout(resolve, ms) })
/**
* Vrai en mode feuille : téléphone ou pointeur grossier, et largeur où la fiche se pose en bas (même règle que la barre).
*/
export function useSheetMode() {
const [sheet, setSheet] = useState(() => (
typeof window !== 'undefined' && window.matchMedia(PHONE_QUERY).matches && window.matchMedia(SHEET_WIDTH_QUERY).matches
))
useEffect(() => {
if (typeof window === 'undefined') return undefined
const phone = window.matchMedia(PHONE_QUERY)
const width = window.matchMedia(SHEET_WIDTH_QUERY)
const update = () => setSheet(phone.matches && width.matches)
phone.addEventListener('change', update)
width.addEventListener('change', update)
return () => {
phone.removeEventListener('change', update)
width.removeEventListener('change', update)
}
}, [])
return sheet
}
/**
* Glisser pour fermer une feuille du bas.
* - Le geste ne part que des éléments de `zone` (sélecteur CSS) et jamais d'un champ de saisie ni d'une sélection de texte.
* - La feuille suit le doigt (transform inline) et ne monte jamais au-delà de sa position de repos.
* - Au lâcher : `closes` décide (par défaut, un tiers de la hauteur ou un lâcher rapide). Fermer attend la fin de la sortie,
* puis appelle `onClose`. Si `onClose` renvoie `false` (enregistrement refusé), la feuille revient.
* - Après un vrai glisser, le clic qui suit est avalé (pas de bouton déclenché au lâcher).
*
* Usage : `<div {...drag.bind} style={drag.style} data-dragging={drag.dragging || undefined}>`
*/
export function useSheetDrag({
enabled = true,
zone,
onClose,
closes = shouldCloseSheet,
closeDelayMs = 180,
}) {
const [offset, setOffset] = useState(0)
const [dragging, setDragging] = useState(false)
const gesture = useRef(null)
const suppressClick = useRef(false)
const closingRef = useRef(false)
const settle = () => {
setDragging(false)
setOffset(0)
}
const close = async (height) => {
closingRef.current = true
setDragging(false)
setOffset(height)
await wait(closeDelayMs)
const kept = await onClose?.()
closingRef.current = false
// Enregistrement refusé : la fiche reste, revient à sa place et l'erreur reste affichée par la fiche
if (kept === false) settle()
}
const onPointerDown = (event) => {
suppressClick.current = false
if (!enabled || closingRef.current || event.button > 0) return
const selection = typeof window !== 'undefined' ? window.getSelection?.()?.toString() ?? '' : ''
if (!canStartSheetDrag(event.target, zone, selection)) return
gesture.current = {
id: event.pointerId,
x: event.clientX,
y: event.clientY,
height: event.currentTarget.offsetHeight,
active: false,
prev: null,
last: { y: event.clientY, t: event.timeStamp },
}
}
const onPointerMove = (event) => {
const g = gesture.current
if (!g || event.pointerId !== g.id) return
const dx = event.clientX - g.x
const dy = event.clientY - g.y
if (!g.active) {
const intent = sheetDragIntent(dx, dy)
if (intent === 'pending') return
const selecting = typeof window !== 'undefined' && window.getSelection?.()?.toString()
if (intent === 'other' || selecting) {
gesture.current = null
return
}
g.active = true
suppressClick.current = true
event.currentTarget.setPointerCapture?.(event.pointerId)
setDragging(true)
}
g.prev = g.last
g.last = { y: event.clientY, t: event.timeStamp }
setOffset(Math.max(0, dy))
}
const onPointerUp = (event) => {
const g = gesture.current
if (!g || event.pointerId !== g.id) return
gesture.current = null
if (!g.active) return
const end = { y: event.clientY, t: event.timeStamp }
const offsetNow = Math.max(0, event.clientY - g.y)
const velocity = velocityBetween(g.prev ?? g.last, end)
if (closes({ offset: offsetNow, height: g.height, velocity })) {
close(g.height)
} else {
settle()
}
}
const onPointerCancel = () => {
const g = gesture.current
gesture.current = null
if (g?.active) settle()
}
// Après un vrai glisser, le clic du lâcher ne doit pas atteindre les boutons de la feuille
const onClickCapture = (event) => {
if (!suppressClick.current) return
suppressClick.current = false
event.stopPropagation()
event.preventDefault()
}
return {
dragging,
bind: { onPointerDown, onPointerMove, onPointerUp, onPointerCancel, onClickCapture },
style: offset > 0 ? { transform: `translateY(${offset}px)` } : undefined,
}
}