Merge pull request #106 from IliesAllali/feat/fiche-glisser-fermer

Fiche : fermer en glissant vers le bas sur téléphone
This commit is contained in:
IliesAllali authored and GitHub committed 2026-10-19 13:12:32 +02:00
commit c3bf4d2f64
9 files changed
+336 -33

No files matched your search

+1 -1
View File
@@ -3191,7 +3191,7 @@ function App() {
{tree.selectedPerson && (
<PersonCard
person={tree.selectedPerson}
onClose={() => { chooseSelectedPerson(null) }}
onClose={() => chooseSelectedPerson(null)}
onMediaSelect={handleMediaSelectFresh}
saveOnLeaveRef={personSaveOnLeaveRef}
editMode={editMode.isActive}
+9 -1
View File
@@ -252,8 +252,15 @@
left: 0; right: 0; bottom: 0; width: auto;
max-height: 78dvh; padding: 10px 18px calc(18px + env(safe-area-inset-bottom));
border-radius: 26px 26px 0 0; border-bottom: 0;
animation: pc-sheet 400ms var(--pz-ease-drawer) both;
/* Pas de fill-mode « both » : une fois l'entrée finie, le glisser (style inline) reprend la main sur transform */
animation: pc-sheet 400ms var(--pz-ease-drawer) backwards;
transition: transform 240ms var(--pz-ease-drawer);
}
/* Pendant le glisser, la feuille suit le doigt sans transition */
.person-card[data-dragging] { transition: none; }
/* La poignée et l'en-tête portent le geste : le navigateur ne doit pas le prendre pour un défilement (les champs gardent le leur) */
.person-card .pc-grab, .person-card .pc-close, .person-card .pc-head { touch-action: none; }
.person-card .pc-head :is(input, textarea, select, label, [contenteditable]) { touch-action: auto; }
.pc-grab { display: block; width: 38px; height: 4px; margin: 0 auto 2px; border-radius: 4px; background: var(--pz-line); flex: none; }
.pc-close { top: 10px; right: 10px; }
.pc-photo { width: 68px; height: 68px; }
@@ -263,6 +270,7 @@
@media (prefers-reduced-motion: reduce) {
.person-card { animation: pz-fade 200ms ease both; }
.person-card { transition-duration: 1ms; }
}
/* Prénom inconnu (issue Anaïs, 18/10) : libellé en italique, case à cocher sous les champs de nom */
+12 -1
View File
@@ -10,6 +10,7 @@ import GeoMediaThumbnail from './GeoMediaThumbnail'
import ImmichFacePanel from './ImmichFacePanel.jsx'
import './PersonCard.css'
import PzBusy from './PzBusy.jsx'
import { useSheetDrag, useSheetMode } from '../hooks/useSheetDrag'
import PortraitCropModal from './PortraitCropModal.jsx'
import { imagePlacement } from '../utils/portraitCrop.js'
import { t, dateLocale } from '../i18n/index.js'
@@ -147,6 +148,9 @@ const PersonCard = ({
const [openedFields, setOpenedFields] = useState([])
const [focusField, setFocusField] = useState(null)
const bodyRef = useRef(null)
// Feuille du bas (téléphone) : on la ferme en la glissant par la poignée ou l'en-tête, comme la croix
const sheetMode = useSheetMode()
const drag = useSheetDrag({ enabled: sheetMode, zone: '.pc-grab, .pc-head, .pc-close', onClose })
// Reset edits quand la personne change
useEffect(() => {
@@ -656,7 +660,14 @@ const PersonCard = ({
)
return (
<div className={`person-card ${editMode ? 'person-card--edit-mode' : ''}`} role="dialog" aria-label={t('Fiche de {name}', { name: fullNameLabel(person) })}>
<div
className={`person-card ${editMode ? 'person-card--edit-mode' : ''}`}
role="dialog"
aria-label={t('Fiche de {name}', { name: fullNameLabel(person) })}
style={drag.style}
data-dragging={drag.dragging || undefined}
{...drag.bind}
>
<span className="pc-grab" aria-hidden="true" />
<button type="button" className="pz-btn pz-btn--ghost pz-btn--icon pc-close" onClick={onClose} aria-label={t('Fermer la fiche')}>
<X size={18} strokeWidth={2} />
@@ -9,6 +9,7 @@ import TextOptions from '../annotations/TextOptions'
import StickerPicker from '../annotations/StickerPicker'
import { toJourneyRole, toOpaqueTreeId, trackAppEvent } from '../../utils/analytics'
import { t } from '../../i18n/index.js'
import { PHONE_QUERY } from '../../utils/sheetDrag'
import './ContextualNavbar.css'
const ANNOT_TOOLS = [
@@ -27,8 +28,7 @@ const ANNOT_MENU = [
{ id: 'photo', label: t('Poser une photo'), Icon: Image },
]
// Téléphone (ou pointeur grossier) : le « + » et le compte s'ouvrent en feuille, sinon en popover
const PHONE_QUERY = '(max-width: 767px), (pointer: coarse)'
// Téléphone (ou pointeur grossier) : le « + » et le compte s'ouvrent en feuille, sinon en popover (PHONE_QUERY, partagé avec la fiche)
const ICON = { size: 18, strokeWidth: 2, 'aria-hidden': true }
function ContextualNavbar({
@@ -44,8 +44,10 @@
box-shadow: 0 -12px 40px rgba(42, 38, 34, 0.16);
/* Pas de fill-mode : une fois l'entrée finie, le glisser (style inline) reprend la main */
animation: navmSheetIn 320ms var(--pz-ease-drawer);
transition: transform 240ms var(--pz-ease-drawer);
overscroll-behavior: contain;
}
.navm-sheet[data-dragging] { transition: none; }
@keyframes navmSheetIn {
from { transform: translateY(100%); }
to { transform: translateY(0); }
@@ -155,4 +157,5 @@
@media (prefers-reduced-motion: reduce) {
.navm-pop, .navm-sheet, .navm-scrim { animation-duration: 1ms; }
.navm-sheet { transition-duration: 1ms; }
}
+14 -28
View File
@@ -1,5 +1,6 @@
import { useEffect, useRef, useState } from 'react'
import { useEffect, useRef } from 'react'
import { createPortal } from 'react-dom'
import { useSheetDrag } from '../../hooks/useSheetDrag'
import './NavMenu.css'
/**
@@ -9,8 +10,8 @@ import './NavMenu.css'
*/
export function NavMenu({ phone = false, label, onClose, triggerRef, children }) {
const ref = useRef(null)
const dragStart = useRef(null)
const [dragY, setDragY] = useState(0)
// Le geste part de la poignée seulement (le contenu de la feuille défile et reste intact)
const drag = useSheetDrag({ enabled: phone, zone: '.navm-handle', onClose, closes: ({ offset }) => offset > 70 })
useEffect(() => {
const node = ref.current
@@ -21,35 +22,20 @@ export function NavMenu({ phone = false, label, onClose, triggerRef, children })
}
}, [triggerRef])
const onHandleDown = (event) => {
dragStart.current = event.clientY
event.currentTarget.setPointerCapture?.(event.pointerId)
}
const onHandleMove = (event) => {
if (dragStart.current === null) return
setDragY(Math.max(0, event.clientY - dragStart.current))
}
const onHandleUp = (event) => {
if (dragStart.current === null) return
const distance = event.clientY - dragStart.current
dragStart.current = null
if (distance > 70) onClose()
else setDragY(0)
}
if (phone) {
return createPortal(
<>
<div className="navm-scrim" onClick={onClose} aria-hidden="true" />
<div ref={ref} className="navm-sheet" role="dialog" aria-label={label} style={dragY ? { transform: `translateY(${dragY}px)` } : undefined}>
<div
className="navm-handle"
onPointerDown={onHandleDown}
onPointerMove={onHandleMove}
onPointerUp={onHandleUp}
onPointerCancel={() => { dragStart.current = null; setDragY(0) }}
aria-hidden="true"
>
<div
ref={ref}
className="navm-sheet"
role="dialog"
aria-label={label}
style={drag.style}
data-dragging={drag.dragging || undefined}
{...drag.bind}
>
<div className="navm-handle" aria-hidden="true">
<span />
</div>
{children}
+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,
}
}
+51
View File
@@ -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
}
+97
View File
@@ -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)
})
})