TypeScript 87.7%
CSS 12.3%
1/**2 * Author: Simon-Pierre Boucher3 * Contact: contact@spboucher.ai4 * Project: Groupe Ka / Ka Maps5 *6 * KaMapBottomSheet — le volet de résultats mobile du Ka Map System v2.7 * Trois crans : mini (poignée + en-tête), half (mi-écran), full (quasi8 * plein écran). Glissement au doigt sur la poignée/l'en-tête, snap au cran9 * le plus proche à la relâche, tap sur la poignée pour passer au cran10 * suivant. Le contenu ne défile qu'en position full — sinon le geste11 * vertical appartient au sheet.12 */1314import {15 useCallback,16 useEffect,17 useRef,18 useState,19 type ReactElement,20 type ReactNode,21} from "react";2223export type KaSheetPosition = "mini" | "half" | "full";2425const POSITIONS: KaSheetPosition[] = ["mini", "half", "full"];2627/** Hauteur cible (px) d'un cran pour un viewport donné. */28function snapHeight(pos: KaSheetPosition, viewport: number): number {29 switch (pos) {30 case "mini":31 return 96;32 case "half":33 return Math.round(viewport * 0.44);34 case "full":35 return Math.round(viewport - 108);36 }37}3839export interface KaMapBottomSheetProps {40 position: KaSheetPosition;41 onPosition: (p: KaSheetPosition) => void;42 /** En-tête toujours visible (compteur, tri) — zone de glissement. */43 header?: ReactNode;44 children: ReactNode;45}4647export function KaMapBottomSheet(props: KaMapBottomSheetProps): ReactElement {48 const { position, onPosition } = props;49 const [dragHeight, setDragHeight] = useState<number | null>(null);50 const dragRef = useRef<{ startY: number; startH: number; moved: boolean } | null>(null);51 const sheetRef = useRef<HTMLDivElement | null>(null);5253 const viewport = () => window.innerHeight;5455 const onPointerDown = useCallback((e: React.PointerEvent) => {56 // Ne pas capturer les gestes commencés sur un élément interactif.57 const target = e.target as HTMLElement;58 if (target.closest("button, a, select, input, label")) return;59 const h = sheetRef.current?.getBoundingClientRect().height ?? 0;60 dragRef.current = { startY: e.clientY, startH: h, moved: false };61 (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);62 }, []);6364 const onPointerMove = useCallback((e: React.PointerEvent) => {65 const drag = dragRef.current;66 if (!drag) return;67 const delta = drag.startY - e.clientY;68 if (Math.abs(delta) > 4) drag.moved = true;69 const max = snapHeight("full", viewport());70 const next = Math.min(max, Math.max(64, drag.startH + delta));71 setDragHeight(next);72 }, []);7374 const settle = useCallback(75 (e: React.PointerEvent) => {76 const drag = dragRef.current;77 dragRef.current = null;78 if (!drag) return;79 try {80 (e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId);81 } catch {82 // capture déjà relâchée83 }84 setDragHeight(null);85 if (!drag.moved) {86 // Tap : cran suivant (mini → half → full → mini).87 const i = POSITIONS.indexOf(position);88 onPosition(POSITIONS[(i + 1) % POSITIONS.length] as KaSheetPosition);89 return;90 }91 const delta = drag.startY - e.clientY;92 const h = drag.startH + delta;93 const vp = viewport();94 let best: KaSheetPosition = "mini";95 let bestD = Infinity;96 for (const p of POSITIONS) {97 const d = Math.abs(snapHeight(p, vp) - h);98 if (d < bestD) {99 bestD = d;100 best = p;101 }102 }103 onPosition(best);104 },105 [position, onPosition],106 );107108 // Recalage à l'orientation/redimensionnement (hauteur en px inline).109 const [, forceRender] = useState(0);110 useEffect(() => {111 const onResize = () => forceRender((x) => x + 1);112 window.addEventListener("resize", onResize);113 return () => window.removeEventListener("resize", onResize);114 }, []);115116 const height = dragHeight ?? snapHeight(position, viewport());117118 return (119 <div120 ref={sheetRef}121 className={`ka-sheet ka-sheet-${position}${dragHeight !== null ? " dragging" : ""}`}122 style={{ height }}123 role="region"124 aria-label="Résultats"125 >126 <div127 className="ka-sheet-grip"128 onPointerDown={onPointerDown}129 onPointerMove={onPointerMove}130 onPointerUp={settle}131 onPointerCancel={settle}132 >133 <span className="ka-sheet-handle" aria-hidden="true" />134 {props.header ? <div className="ka-sheet-head">{props.header}</div> : null}135 </div>136 <div className="ka-sheet-body" aria-hidden={position === "mini"}>137 {props.children}138 </div>139 </div>140 );141}142