Lou·Ka — tous les logements à louer du Québec, un seul endroit.
HTML 98.9%
Python 0.6%
1// -----------------------------------------------------------------------------2// Lou-Ka — Agrégateur de logements à louer (province de Québec)3// Auteur : Simon-Pierre Boucher — contact@spboucher.ai4// fiche/ui.tsx : primitives d'interface de la fiche logement (refonte 2026-09-04)5// SectionCard · Accordion · StatTile · StatusBadge · Skeleton · EmptyState ·6// ErrorState · MoreButton · SourceLine · useToast — sans dépendance externe,7// ARIA correcte, cibles tactiles ≥ 44 px, animations légères.8// -----------------------------------------------------------------------------9import { ReactNode, useCallback, useEffect, useId, useState } from "react";10import { createPortal } from "react-dom";11import { IcoChevronDown } from "../components/Icons";1213export type Tone = "good" | "warn" | "bad" | "neutral" | "info";1415/* --- carte de section ------------------------------------------------------ */16export function SectionCard({ id, title, icon, sub, aside, children, className = "", label }: {17 id?: string; title?: ReactNode; icon?: ReactNode; sub?: ReactNode; aside?: ReactNode;18 children: ReactNode; className?: string; label?: string;19}) {20 return (21 <section id={id} className={`lk-card ${className}`} aria-label={label}>22 {(title || aside) && (23 <div className="lk-card-head">24 <div>25 {title && <h2 className="lk-card-title">{icon}{title}</h2>}26 {sub && <p className="lk-card-sub">{sub}</p>}27 </div>28 {aside && <div className="lk-card-aside">{aside}</div>}29 </div>30 )}31 {children}32 </section>33 );34}3536/* --- accordéon accessible (bouton + région, animation grid-rows) ----------- */37export function Accordion({ title, meta, children, defaultOpen = false, small = false, onToggle }: {38 title: ReactNode; meta?: ReactNode; children: ReactNode; defaultOpen?: boolean;39 small?: boolean; onToggle?: (open: boolean) => void;40}) {41 const [open, setOpen] = useState(defaultOpen);42 const id = useId();43 return (44 <div className={`lk-acc ${small ? "sm" : ""}`}>45 <button type="button" className="lk-acc-btn" aria-expanded={open}46 aria-controls={`${id}-body`} id={`${id}-btn`}47 onClick={() => { setOpen(!open); onToggle?.(!open); }}>48 <span>{title}</span>49 {meta && <span className="lk-acc-meta">{meta}</span>}50 <IcoChevronDown size={18} className="chev" />51 </button>52 <div className={`lk-acc-body ${open ? "open" : ""}`} id={`${id}-body`}53 role="region" aria-labelledby={`${id}-btn`}>54 <div><div className="lk-acc-inner">{children}</div></div>55 </div>56 </div>57 );58}5960/* --- tuile KPI ------------------------------------------------------------- */61export function StatTile({ value, unit, label, accent = false, anim = true }: {62 value: ReactNode; unit?: ReactNode; label: ReactNode; accent?: boolean; anim?: boolean;63}) {64 return (65 <div className={`lk-kpi ${accent ? "accent" : ""} ${anim ? "anim" : ""}`}>66 <div className={`lk-kpi-v ${typeof value === "string" && value.length > 8 ? "wrap" : ""}`}>{value}{unit && <small>{unit}</small>}</div>67 <div className="lk-kpi-l">{label}</div>68 </div>69 );70}7172/* --- pastille d'état ------------------------------------------------------- */73export function StatusBadge({ tone = "neutral", children, lg = false }: {74 tone?: Tone; children: ReactNode; lg?: boolean;75}) {76 return <span className={`lk-badge ${tone} ${lg ? "lg" : ""}`}>{children}</span>;77}7879/* --- squelettes ------------------------------------------------------------ */80export function Skeleton({ h = 14, w, r, className = "" }: { h?: number | string; w?: number | string; r?: number; className?: string }) {81 return <div className={`lk-skel ${className}`} style={{ height: h, width: w ?? "100%", borderRadius: r }} aria-hidden="true" />;82}83export function SkeletonLines({ n = 3 }: { n?: number }) {84 return (85 <div className="lk-skel-lines" aria-busy="true">86 {Array.from({ length: n }).map((_, i) => (87 <div key={i} className={`lk-skel ${i === n - 1 ? "short" : ""}`} />88 ))}89 </div>90 );91}9293/* --- états vides / erreur -------------------------------------------------- */94export function EmptyState({ children = "Aucune donnée disponible pour ce secteur." }: { children?: ReactNode }) {95 return <div className="lk-empty">{children}</div>;96}97export function ErrorState({ onRetry, children = "Données temporairement indisponibles." }: {98 onRetry?: () => void; children?: ReactNode;99}) {100 return (101 <div className="lk-error" role="alert">102 <span>{children}</span>103 {onRetry && <button type="button" onClick={onRetry}>Réessayer</button>}104 </div>105 );106}107108/* --- bouton « Voir plus » pleine largeur ----------------------------------- */109export function MoreButton({ children, onClick, expanded }: {110 children: ReactNode; onClick: () => void; expanded?: boolean;111}) {112 return (113 <button type="button" className="lk-more" onClick={onClick} aria-expanded={expanded}>114 {children}115 <IcoChevronDown size={16} style={expanded ? { transform: "rotate(180deg)" } : undefined} />116 </button>117 );118}119120/* --- ligne « Source : … · Méthodologie » en pied de section ---------------- */121export function SourceLine({ name, href, date, onMethod, methodLabel = "Méthodologie" }: {122 name: ReactNode; href?: string; date?: ReactNode; onMethod?: () => void; methodLabel?: string;123}) {124 return (125 <div className="lk-source">126 <span>127 Source : <b>{href ? <a href={href} target="_blank" rel="noopener noreferrer">{name}</a> : name}</b>128 {date && <> · {date}</>}129 </span>130 {onMethod && <button type="button" onClick={onMethod}>{methodLabel}</button>}131 </div>132 );133}134135/* --- toast minimal (retour d'action : favoris, lien copié) ------------------ */136export function useToast(): [ReactNode, (msg: string) => void] {137 const [msg, setMsg] = useState<string | null>(null);138 useEffect(() => {139 if (!msg) return;140 const t = setTimeout(() => setMsg(null), 2200);141 return () => clearTimeout(t);142 }, [msg]);143 const show = useCallback((m: string) => setMsg(m), []);144 const node = msg145 ? createPortal(<div className="lk-toast" role="status" aria-live="polite">{msg}</div>, document.body)146 : null;147 return [node, show];148}149150/* --- utilitaires de format -------------------------------------------------- */151export const NBSP = " ";152export const fmtN = (v: number, d = 0) =>153 v.toLocaleString("fr-CA", { maximumFractionDigits: d, minimumFractionDigits: d });154export const fmtPct = (v: number, signed = true) =>155 `${signed ? (v > 0 ? "+" : v < 0 ? "−" : "") : ""}${Math.abs(Math.round(v))}${NBSP}%`;156/** ≈ minutes de marche (vol d'oiseau × 1,3 de détour, 4,8 km/h) */157export const marcheMin = (m: number) => Math.max(1, Math.round((m * 1.3) / 80));158export const fmtMarche = (m: number) => `${marcheMin(m)}${NBSP}min`;159export const relTime = (ts: number | null | undefined): string | null => {160 if (!ts) return null;161 const s = Date.now() / 1000 - ts;162 if (s < 3600) return "à l'instant";163 if (s < 86400) return `il y a ${Math.round(s / 3600)}${NBSP}h`;164 const j = Math.round(s / 86400);165 if (j < 30) return `il y a ${j}${NBSP}jour${j > 1 ? "s" : ""}`;166 return new Date(ts * 1000).toLocaleDateString("fr-CA", { day: "numeric", month: "short", year: "numeric" });167};168