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,51 @@
|
||||
// Geste « feuille » : une feuille du bas (fiche de personne, menu de la barre) se ferme en glissant vers le bas.
|
||||
// Logique pure, sans React ni DOM, testée dans sheetDrag.test.js. Le hook qui l'utilise est hooks/useSheetDrag.js.
|
||||
|
||||
// Téléphone ou pointeur grossier : même règle pour la barre et pour la fiche
|
||||
export const PHONE_QUERY = '(max-width: 767px), (pointer: coarse)'
|
||||
// Même seuil que la feuille CSS de la fiche (PersonCard.css) : au-delà, la fiche est un panneau flottant
|
||||
export const SHEET_WIDTH_QUERY = '(max-width: 768px)'
|
||||
|
||||
// Distance avant de trancher entre glisser vertical, horizontal et tap
|
||||
export const DIRECTION_LOCK_PX = 8
|
||||
// Lâcher rapide vers le bas : vitesse en px/ms, et course minimale pour qu'il compte
|
||||
export const FLICK_VELOCITY = 0.6
|
||||
export const FLICK_MIN_OFFSET = 24
|
||||
// Deux points plus espacés que ça ne donnent pas une vitesse fiable
|
||||
const VELOCITY_WINDOW_MS = 120
|
||||
|
||||
// Champs de saisie et libellés : jamais le point de départ d'un glisser
|
||||
export const NO_DRAG_SELECTOR = 'input, textarea, select, label, [contenteditable]:not([contenteditable="false"]), [data-sheet-no-drag]'
|
||||
|
||||
/**
|
||||
* Le glisser peut-il partir de cette cible ?
|
||||
* `zoneSelector` : les seuls endroits de la feuille qui déclenchent le geste (poignée, en-tête).
|
||||
* `selectionText` : texte sélectionné au moment de l'appui, auquel cas rien ne bouge.
|
||||
*/
|
||||
export function canStartSheetDrag(target, zoneSelector, selectionText = '') {
|
||||
if (!target || typeof target.closest !== 'function') return false
|
||||
if (selectionText) return false
|
||||
if (!target.closest(zoneSelector)) return false
|
||||
return !target.closest(NO_DRAG_SELECTOR)
|
||||
}
|
||||
|
||||
/** Intention du mouvement depuis l'appui : 'pending' (encore trop petit), 'down' (glisser vers le bas), 'other' (haut ou horizontal) */
|
||||
export function sheetDragIntent(dx, dy) {
|
||||
if (Math.abs(dx) < DIRECTION_LOCK_PX && Math.abs(dy) < DIRECTION_LOCK_PX) return 'pending'
|
||||
if (dy > 0 && dy >= Math.abs(dx)) return 'down'
|
||||
return 'other'
|
||||
}
|
||||
|
||||
/** Vitesse (px/ms, positive vers le bas) entre deux points {y, t}. Zéro si les points sont trop éloignés dans le temps. */
|
||||
export function velocityBetween(from, to) {
|
||||
if (!from || !to) return 0
|
||||
const dt = to.t - from.t
|
||||
if (dt <= 0 || dt > VELOCITY_WINDOW_MS) return 0
|
||||
return (to.y - from.y) / dt
|
||||
}
|
||||
|
||||
/** Au lâcher : fermer si la course dépasse un tiers de la hauteur, ou si le lâcher est rapide vers le bas */
|
||||
export function shouldCloseSheet({ offset, height, velocity = 0 }) {
|
||||
if (height > 0 && offset >= height / 3) return true
|
||||
return velocity >= FLICK_VELOCITY && offset >= FLICK_MIN_OFFSET
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
NO_DRAG_SELECTOR,
|
||||
canStartSheetDrag,
|
||||
shouldCloseSheet,
|
||||
sheetDragIntent,
|
||||
velocityBetween,
|
||||
} from './sheetDrag'
|
||||
|
||||
const ZONE = '.pc-grab, .pc-head, .pc-close'
|
||||
|
||||
// Cible factice : `closest` ne répond que pour la zone et les champs demandés
|
||||
function target({ inZone = true, inField = false } = {}) {
|
||||
return {
|
||||
closest(selector) {
|
||||
if (selector === ZONE) return inZone ? {} : null
|
||||
if (selector === NO_DRAG_SELECTOR) return inField ? {} : null
|
||||
return null
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
describe('canStartSheetDrag (zones autorisées)', () => {
|
||||
it('part de la poignée, de l\'en-tête ou de la croix', () => {
|
||||
expect(canStartSheetDrag(target(), ZONE)).toBe(true)
|
||||
})
|
||||
|
||||
it('ne part pas du contenu hors zone (onglets, sections)', () => {
|
||||
expect(canStartSheetDrag(target({ inZone: false }), ZONE)).toBe(false)
|
||||
})
|
||||
|
||||
it('ne part jamais d\'un champ de saisie ou d\'un libellé de l\'en-tête', () => {
|
||||
expect(canStartSheetDrag(target({ inField: true }), ZONE)).toBe(false)
|
||||
})
|
||||
|
||||
it('ne part pas pendant une sélection de texte', () => {
|
||||
expect(canStartSheetDrag(target(), ZONE, 'Marie')).toBe(false)
|
||||
expect(canStartSheetDrag(target(), ZONE, '')).toBe(true)
|
||||
})
|
||||
|
||||
it('refuse une cible sans closest (pas un élément DOM)', () => {
|
||||
expect(canStartSheetDrag(null, ZONE)).toBe(false)
|
||||
expect(canStartSheetDrag({}, ZONE)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('sheetDragIntent (direction)', () => {
|
||||
it('attend un mouvement suffisant avant de trancher', () => {
|
||||
expect(sheetDragIntent(0, 0)).toBe('pending')
|
||||
expect(sheetDragIntent(3, 5)).toBe('pending')
|
||||
})
|
||||
|
||||
it('reconnaît un glisser vers le bas', () => {
|
||||
expect(sheetDragIntent(0, 20)).toBe('down')
|
||||
expect(sheetDragIntent(10, 20)).toBe('down')
|
||||
})
|
||||
|
||||
it('rejette le haut et l\'horizontal', () => {
|
||||
expect(sheetDragIntent(0, -20)).toBe('other')
|
||||
expect(sheetDragIntent(30, 20)).toBe('other')
|
||||
})
|
||||
})
|
||||
|
||||
describe('shouldCloseSheet (seuil et vitesse)', () => {
|
||||
it('ferme au-delà d\'un tiers de la hauteur', () => {
|
||||
expect(shouldCloseSheet({ offset: 100, height: 300 })).toBe(true)
|
||||
expect(shouldCloseSheet({ offset: 99, height: 300 })).toBe(false)
|
||||
})
|
||||
|
||||
it('ferme sur un lâcher rapide vers le bas, même petit', () => {
|
||||
expect(shouldCloseSheet({ offset: 30, height: 600, velocity: 0.7 })).toBe(true)
|
||||
})
|
||||
|
||||
it('ne ferme pas sur un lâcher rapide mais trop court', () => {
|
||||
expect(shouldCloseSheet({ offset: 10, height: 600, velocity: 2 })).toBe(false)
|
||||
})
|
||||
|
||||
it('ne ferme pas sur une course lente et courte', () => {
|
||||
expect(shouldCloseSheet({ offset: 60, height: 600, velocity: 0.1 })).toBe(false)
|
||||
})
|
||||
|
||||
it('ne ferme jamais sans déplacement vers le bas', () => {
|
||||
expect(shouldCloseSheet({ offset: 0, height: 300, velocity: 5 })).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('velocityBetween', () => {
|
||||
it('donne la vitesse vers le bas en px/ms', () => {
|
||||
expect(velocityBetween({ y: 100, t: 0 }, { y: 160, t: 60 })).toBe(1)
|
||||
})
|
||||
|
||||
it('est nulle si les points manquent, ne se suivent pas ou sont trop espacés', () => {
|
||||
expect(velocityBetween(null, { y: 1, t: 1 })).toBe(0)
|
||||
expect(velocityBetween({ y: 0, t: 10 }, { y: 50, t: 10 })).toBe(0)
|
||||
expect(velocityBetween({ y: 0, t: 0 }, { y: 50, t: 500 })).toBe(0)
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user