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

+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'
@@ -143,6 +144,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(() => {
@@ -634,7 +638,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}