mirror of
https://github.com/IliesAllali/parenthese.git
synced 2026-11-15 08:32:03 +00:00
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:
1 parent
0d08b0f148
commit
6faaefebee
9 files changed
+336
-33
No files matched your search
@@ -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,
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user