SPB Git forge

spb/job-ka

Public
229commits 1branches 0releases
38.1 MBsize
maindefault branch
1 h agolast push
HTML 82.1% Python 14.6% TypeScript 1.9% CSS 1% JavaScript 0.5%
7.7 KB · 178 lines tsx
Raw Blame History
1// -----------------------------------------------------------------------------2// Job·Ka — Agrégateur d'offres d'emploi (province de Québec)3// Auteur : Simon-Pierre Boucher — contact@spboucher.ai4// fiche/ui.tsx : primitives d'interface de la fiche d'offre (refonte5//   premium 2026-09-07, même socle que la fiche Lou-Ka v3) :6//   SectionCard · Accordion · StatTile · StatusBadge · Skeleton · EmptyState ·7//   ErrorState · MoreButton · SourceLine · useToast — sans dépendance externe,8//   ARIA correcte, cibles tactiles ≥ 44 px, animations légères.9// -----------------------------------------------------------------------------10import { ReactNode, useCallback, useEffect, useId, useState } from "react";11import { createPortal } from "react-dom";12import { Ico } from "../components/Icons";1314export type Tone = "good" | "warn" | "bad" | "neutral" | "info";1516/* --- carte de section ------------------------------------------------------ */17export function SectionCard({ id, title, icon, sub, aside, children, className = "", label }: {18  id?: string; title?: ReactNode; icon?: ReactNode; sub?: ReactNode; aside?: ReactNode;19  children: ReactNode; className?: string; label?: string;20}) {21  return (22    <section id={id} className={`jk-card ${className}`} aria-label={label}>23      {(title || aside) && (24        <div className="jk-card-head">25          <div>26            {title && <h2 className="jk-card-title">{icon}{title}</h2>}27            {sub && <p className="jk-card-sub">{sub}</p>}28          </div>29          {aside && <div className="jk-card-aside">{aside}</div>}30        </div>31      )}32      {children}33    </section>34  );35}3637/* --- accordéon accessible (bouton + région, animation grid-rows) ----------- */38export function Accordion({ title, meta, children, defaultOpen = false, small = false, onToggle }: {39  title: ReactNode; meta?: ReactNode; children: ReactNode; defaultOpen?: boolean;40  small?: boolean; onToggle?: (open: boolean) => void;41}) {42  const [open, setOpen] = useState(defaultOpen);43  const id = useId();44  return (45    <div className={`jk-acc ${small ? "sm" : ""}`}>46      <button type="button" className="jk-acc-btn" aria-expanded={open}47              aria-controls={`${id}-body`} id={`${id}-btn`}48              onClick={() => { setOpen(!open); onToggle?.(!open); }}>49        <span>{title}</span>50        {meta && <span className="jk-acc-meta">{meta}</span>}51        <Ico name="chevdown" size={18} className="chev" />52      </button>53      <div className={`jk-acc-body ${open ? "open" : ""}`} id={`${id}-body`}54           role="region" aria-labelledby={`${id}-btn`}>55        <div><div className="jk-acc-inner">{children}</div></div>56      </div>57    </div>58  );59}6061/* --- tuile KPI ------------------------------------------------------------- */62export function StatTile({ value, unit, label, accent = false, anim = true }: {63  value: ReactNode; unit?: ReactNode; label: ReactNode; accent?: boolean; anim?: boolean;64}) {65  return (66    <div className={`jk-kpi ${accent ? "accent" : ""} ${anim ? "anim" : ""}`}>67      <div className={`jk-kpi-v ${typeof value === "string" && value.length > 8 ? "wrap" : ""}`}>{value}{unit && <small>{unit}</small>}</div>68      <div className="jk-kpi-l">{label}</div>69    </div>70  );71}7273/* --- pastille d'état ------------------------------------------------------- */74export function StatusBadge({ tone = "neutral", children, lg = false }: {75  tone?: Tone; children: ReactNode; lg?: boolean;76}) {77  return <span className={`jk-badge ${tone} ${lg ? "lg" : ""}`}>{children}</span>;78}7980/* --- squelettes ------------------------------------------------------------ */81export function Skeleton({ h = 14, w, r, className = "" }: { h?: number | string; w?: number | string; r?: number; className?: string }) {82  return <div className={`jk-skel ${className}`} style={{ height: h, width: w ?? "100%", borderRadius: r }} aria-hidden="true" />;83}84export function SkeletonLines({ n = 3 }: { n?: number }) {85  return (86    <div className="jk-skel-lines" aria-busy="true">87      {Array.from({ length: n }).map((_, i) => (88        <div key={i} className={`jk-skel ${i === n - 1 ? "short" : ""}`} />89      ))}90    </div>91  );92}9394/* --- états vides / erreur -------------------------------------------------- */95export function EmptyState({ children = "Aucune donnée disponible pour ce secteur." }: { children?: ReactNode }) {96  return <div className="jk-empty">{children}</div>;97}98export function ErrorState({ onRetry, children = "Données temporairement indisponibles." }: {99  onRetry?: () => void; children?: ReactNode;100}) {101  return (102    <div className="jk-error" role="alert">103      <span>{children}</span>104      {onRetry && <button type="button" onClick={onRetry}>Réessayer</button>}105    </div>106  );107}108109/* --- bouton « Voir plus » pleine largeur ----------------------------------- */110export function MoreButton({ children, onClick, expanded }: {111  children: ReactNode; onClick: () => void; expanded?: boolean;112}) {113  return (114    <button type="button" className="jk-more" onClick={onClick} aria-expanded={expanded}>115      {children}116      <Ico name="chevdown" size={16} className={expanded ? "flip" : ""} />117    </button>118  );119}120121/* --- ligne « Source : … · Méthodologie » en pied de section ---------------- */122export function SourceLine({ name, href, date, onMethod, methodLabel = "Méthodologie" }: {123  name: ReactNode; href?: string; date?: ReactNode; onMethod?: () => void; methodLabel?: string;124}) {125  return (126    <div className="jk-source">127      <span>128        Source : <b>{href ? <a href={href} target="_blank" rel="noopener noreferrer">{name}</a> : name}</b>129        {date && <> · {date}</>}130      </span>131      {onMethod && <button type="button" onClick={onMethod}>{methodLabel}</button>}132    </div>133  );134}135136/* --- toast minimal (retour d'action : favoris, lien copié) ------------------ */137export function useToast(): [ReactNode, (msg: string) => void] {138  const [msg, setMsg] = useState<string | null>(null);139  useEffect(() => {140    if (!msg) return;141    const t = setTimeout(() => setMsg(null), 2200);142    return () => clearTimeout(t);143  }, [msg]);144  const show = useCallback((m: string) => setMsg(m), []);145  const node = msg146    ? createPortal(<div className="jk-toast" role="status" aria-live="polite">{msg}</div>, document.body)147    : null;148  return [node, show];149}150151/* --- utilitaires de format -------------------------------------------------- */152export const NBSP = " ";153export const fmtN = (v: number, d = 0) =>154  v.toLocaleString("fr-CA", { maximumFractionDigits: d, minimumFractionDigits: d });155export const fmtPct = (v: number, signed = true) =>156  `${signed ? (v > 0 ? "+" : v < 0 ? "−" : "") : ""}${Math.abs(Math.round(v))}${NBSP}%`;157/** ≈ minutes de marche (vol d'oiseau × 1,3 de détour, 4,8 km/h) */158export const marcheMin = (m: number) => Math.max(1, Math.round((m * 1.3) / 80));159export const fmtMarche = (m: number) => `${marcheMin(m)}${NBSP}min`;160export const relTime = (ts: number | null | undefined): string | null => {161  if (!ts) return null;162  const s = Date.now() / 1000 - ts;163  if (s < 3600) return "à l'instant";164  if (s < 86400) return `il y a ${Math.round(s / 3600)}${NBSP}h`;165  const j = Math.round(s / 86400);166  if (j < 30) return `il y a ${j}${NBSP}jour${j > 1 ? "s" : ""}`;167  return new Date(ts * 1000).toLocaleDateString("fr-CA", { day: "numeric", month: "short", year: "numeric" });168};169/** « 19 989 $ (2026) » → 19989 ; « 1 640 pi² » → 1640 ; sinon null */170export const parseMontant = (v: unknown): number | null => {171  if (v == null) return null;172  const s = String(v).replace(/\(.*?\)/g, "").replace(/[^\d.,]/g, "").replace(/\s/g, "");173  if (!s) return null;174  // « 19 989 » et « 19989,50 » : la virgule est décimale, le point aussi175  const n = parseFloat(s.replace(/,(\d{1,2})$/, ".$1").replace(/,/g, ""));176  return Number.isFinite(n) && n > 0 ? n : null;177};178