TypeScript 87.7%
CSS 12.3%
1/**2 * Author: Simon-Pierre Boucher3 * Contact: contact@spboucher.ai4 * Project: Groupe Ka / Ka Maps5 *6 * KaPropertyPreview — la fiche contextuelle v2. Tap sur une pastille :7 * pas de navigation, une carte élégante — photo, prix, adresse, traits8 * principaux, lien fiche (contenu rendu par l'app, render prop).9 * · mobile : bottom card flottante, glissement horizontal pour passer10 * aux propriétés voisines (l'ordre de proximité est figé à l'ouverture) ;11 * · desktop : carte flottante ancrée en bas à gauche de la carte ;12 * · chevrons ‹ › des deux côtés, Échap/✕ pour fermer.13 */1415import {16 useEffect,17 useRef,18 useState,19 type ReactElement,20 type ReactNode,21} from "react";22import type { MapProperty } from "../types/index.js";23import { haversineMeters } from "../utils/geo.js";24import { useKaMap } from "./KaMapView.js";2526export interface KaPropertyPreviewProps {27 /** Rendu du CONTENU (photo, prix, traits, favori…) — app-owned. */28 render: (property: MapProperty, close: () => void) => ReactNode;29 /** Navigation ‹ › + swipe entre propriétés proches. Défaut : activée. */30 withNav?: boolean;31 mobileBreakpoint?: number;32 closeLabel?: string;33}3435export function KaPropertyPreview(props: KaPropertyPreviewProps): ReactElement | null {36 const map = useKaMap();37 const [property, setProperty] = useState<MapProperty | null>(null);38 const [mobile, setMobile] = useState(false);39 const [slide, setSlide] = useState<"left" | "right" | null>(null);40 const breakpoint = props.mobileBreakpoint ?? 780;4142 /** Ordre de navigation figé quand la fiche s'ouvre : propriétés triées43 * par distance de l'élément sélectionné (les « voisines » d'abord). */44 const orderRef = useRef<string[]>([]);45 const touchRef = useRef<{ x: number; y: number } | null>(null);4647 useEffect(() => {48 if (!map) return;49 return map.events.on("select", ({ propertyId }) => {50 const p = propertyId ? map.getProperty(propertyId) ?? null : null;51 setProperty((previous) => {52 if (p && !previous) {53 // ouverture : figer l'ordre de proximité autour de p54 orderRef.current = map55 .getVisibleProperties()56 .map((item) => ({57 id: item.id,58 d: haversineMeters(p.latitude, p.longitude, item.latitude, item.longitude),59 }))60 .sort((a, b) => a.d - b.d)61 .map((item) => item.id);62 }63 if (!p) orderRef.current = [];64 return p;65 });66 });67 }, [map]);6869 useEffect(() => {70 const mq = window.matchMedia(`(max-width: ${breakpoint}px)`);71 const update = () => setMobile(mq.matches);72 update();73 mq.addEventListener("change", update);74 return () => mq.removeEventListener("change", update);75 }, [breakpoint]);7677 useEffect(() => {78 if (!property || !map) return;79 const onKey = (e: KeyboardEvent) => {80 if (e.key === "Escape") map.select(null, "app");81 if (e.key === "ArrowRight") step(1);82 if (e.key === "ArrowLeft") step(-1);83 };84 window.addEventListener("keydown", onKey);85 return () => window.removeEventListener("keydown", onKey);86 // eslint-disable-next-line react-hooks/exhaustive-deps87 }, [property, map]);8889 if (!map || !property) return null;9091 const close = () => map.select(null, "app");9293 const order = orderRef.current;94 const index = order.indexOf(property.id);95 const canNav = props.withNav !== false && order.length > 1 && index !== -1;9697 const step = (dir: 1 | -1) => {98 if (!canNav) return;99 const next = order[(index + dir + order.length) % order.length];100 if (!next) return;101 setSlide(dir === 1 ? "left" : "right");102 map.select(next, "app");103 };104105 const onTouchStart = (e: React.TouchEvent) => {106 const t = e.touches[0];107 if (t) touchRef.current = { x: t.clientX, y: t.clientY };108 };109 const onTouchEnd = (e: React.TouchEvent) => {110 const start = touchRef.current;111 touchRef.current = null;112 const t = e.changedTouches[0];113 if (!start || !t) return;114 const dx = t.clientX - start.x;115 const dy = t.clientY - start.y;116 if (Math.abs(dx) > 52 && Math.abs(dx) > Math.abs(dy) * 1.4) {117 step(dx < 0 ? 1 : -1);118 }119 };120121 return (122 <div123 className={`ka-prev ${mobile ? "ka-prev-mobile" : "ka-prev-desktop"}`}124 role="dialog"125 aria-label={property.address ?? "Propriété sélectionnée"}126 onTouchStart={onTouchStart}127 onTouchEnd={onTouchEnd}128 >129 <button130 type="button"131 className="ka-prev-close"132 onClick={close}133 aria-label={props.closeLabel ?? "Fermer"}134 >135 <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.6" strokeLinecap="round" aria-hidden="true">136 <path d="M18 6L6 18M6 6l12 12" />137 </svg>138 </button>139 {canNav ? (140 <>141 <button142 type="button"143 className="ka-prev-nav ka-prev-prev"144 onClick={() => step(-1)}145 aria-label="Propriété précédente"146 >147 <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.6" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">148 <path d="M15 18l-6-6 6-6" />149 </svg>150 </button>151 <button152 type="button"153 className="ka-prev-nav ka-prev-next"154 onClick={() => step(1)}155 aria-label="Propriété suivante"156 >157 <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.6" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">158 <path d="M9 6l6 6-6 6" />159 </svg>160 </button>161 </>162 ) : null}163 <div164 key={property.id}165 className={`ka-prev-card${slide ? ` ka-slide-${slide}` : ""}`}166 onAnimationEnd={() => setSlide(null)}167 >168 {props.render(property, close)}169 </div>170 {canNav ? (171 <div className="ka-prev-pos" aria-hidden="true">172 {index + 1} / {order.length}173 </div>174 ) : null}175 </div>176 );177}178