/** * Author: Simon-Pierre Boucher * Contact: contact@spboucher.ai * Project: Groupe Ka / Ka Maps * * KaMapBottomSheet — le volet de résultats mobile du Ka Map System v2. * Trois crans : mini (poignée + en-tête), half (mi-écran), full (quasi * plein écran). Glissement au doigt sur la poignée/l'en-tête, snap au cran * le plus proche à la relâche, tap sur la poignée pour passer au cran * suivant. Le contenu ne défile qu'en position full — sinon le geste * vertical appartient au sheet. */ import { useCallback, useEffect, useRef, useState, type ReactElement, type ReactNode, } from "react"; export type KaSheetPosition = "mini" | "half" | "full"; const POSITIONS: KaSheetPosition[] = ["mini", "half", "full"]; /** Hauteur cible (px) d'un cran pour un viewport donné. */ function snapHeight(pos: KaSheetPosition, viewport: number): number { switch (pos) { case "mini": return 96; case "half": return Math.round(viewport * 0.44); case "full": return Math.round(viewport - 108); } } export interface KaMapBottomSheetProps { position: KaSheetPosition; onPosition: (p: KaSheetPosition) => void; /** En-tête toujours visible (compteur, tri) — zone de glissement. */ header?: ReactNode; children: ReactNode; } export function KaMapBottomSheet(props: KaMapBottomSheetProps): ReactElement { const { position, onPosition } = props; const [dragHeight, setDragHeight] = useState(null); const dragRef = useRef<{ startY: number; startH: number; moved: boolean } | null>(null); const sheetRef = useRef(null); const viewport = () => window.innerHeight; const onPointerDown = useCallback((e: React.PointerEvent) => { // Ne pas capturer les gestes commencés sur un élément interactif. const target = e.target as HTMLElement; if (target.closest("button, a, select, input, label")) return; const h = sheetRef.current?.getBoundingClientRect().height ?? 0; dragRef.current = { startY: e.clientY, startH: h, moved: false }; (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId); }, []); const onPointerMove = useCallback((e: React.PointerEvent) => { const drag = dragRef.current; if (!drag) return; const delta = drag.startY - e.clientY; if (Math.abs(delta) > 4) drag.moved = true; const max = snapHeight("full", viewport()); const next = Math.min(max, Math.max(64, drag.startH + delta)); setDragHeight(next); }, []); const settle = useCallback( (e: React.PointerEvent) => { const drag = dragRef.current; dragRef.current = null; if (!drag) return; try { (e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId); } catch { // capture déjà relâchée } setDragHeight(null); if (!drag.moved) { // Tap : cran suivant (mini → half → full → mini). const i = POSITIONS.indexOf(position); onPosition(POSITIONS[(i + 1) % POSITIONS.length] as KaSheetPosition); return; } const delta = drag.startY - e.clientY; const h = drag.startH + delta; const vp = viewport(); let best: KaSheetPosition = "mini"; let bestD = Infinity; for (const p of POSITIONS) { const d = Math.abs(snapHeight(p, vp) - h); if (d < bestD) { bestD = d; best = p; } } onPosition(best); }, [position, onPosition], ); // Recalage à l'orientation/redimensionnement (hauteur en px inline). const [, forceRender] = useState(0); useEffect(() => { const onResize = () => forceRender((x) => x + 1); window.addEventListener("resize", onResize); return () => window.removeEventListener("resize", onResize); }, []); const height = dragHeight ?? snapHeight(position, viewport()); return (
{props.children}
); }