SPB Git forge

spb/lou-ka

Public

Lou·Ka — tous les logements à louer du Québec, un seul endroit.

232commits 1branches 0releases
172.9 MBsize
maindefault branch
2 days agolast push
HTML 98.9% Python 0.6%
7.3 KB · 168 lines tsx
Raw Blame History
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