<!DOCTYPE html>
<html lang="fr" data-theme="light">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Technologie HTMX, l'hypermedia et le web qui répond en HTML</title>
<meta name="description" content="hypermedia.be vous présente cette techno web sous forme de guide interactif : concepts, attributs, démos live et cas d'usage pour webmasters et développeurs francophones.">
<meta name="color-scheme" content="light dark">
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Crect width='100' height='100' rx='22' fill='%2312181c'/%3E%3Ctext x='50' y='67' font-size='46' text-anchor='middle' fill='%232a5fd9' font-family='monospace' font-weight='bold'%3E%3E_%3C/text%3E%3C/svg%3E">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@500;600;700&family=Source+Serif+4:opsz,wght@8..60,400;8..60,600;8..60,700&family=JetBrains+Mono:wght@400;500;700&display=swap" rel="stylesheet">
<script src="https://cdn.jsdelivr.net/npm/htmx.org@2.0.10/dist/htmx.min.js" integrity="sha384-H5SrcfygHmAuTDZphMHqBJLc3FhssKjG7w/CeCpFReSfwBWDTKpkzPP8c+cLsK+V" crossorigin="anonymous"></script>
<style>
:root{
--ink:#12181C; --ink-soft:#4a555b; --paper:#EEF0EA; --paper-raised:#FFFFFF;
--line:rgba(18,24,28,.14); --link:#2A5FD9; --visited:#7C4DFF; --focus:#F5A623;
--ok:#1B8F63; --danger:#D93B31; --code-bg:#11161A; --code-fg:#E7EBEE;
--code-comment:#7C8992; --code-attr:#7FD1D9; --code-string:#F5A623; --code-tag:#B79CFF;
--radius:10px; --header-h:64px;
--mono:'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, monospace;
--display:'Space Grotesk', ui-sans-serif, system-ui, sans-serif;
--serif:'Source Serif 4', Georgia, 'Times New Roman', serif;
}
[data-theme="dark"]{
--ink:#ECEFEA; --ink-soft:#B7C1C6; --paper:#0F1417; --paper-raised:#171E22;
--line:rgba(236,239,234,.14); --link:#7CA4FF; --visited:#C4AEFF;
}
*,*::before,*::after{ box-sizing:border-box; }
html{ scroll-behavior:smooth; }
@media (prefers-reduced-motion: reduce){
html{ scroll-behavior:auto; }
*,*::before,*::after{ animation-duration:.001ms !important; animation-iteration-count:1 !important; transition-duration:.001ms !important; }
}
body{
margin:0; background:var(--paper); color:var(--ink); font-family:var(--serif);
line-height:1.65; font-size:1rem; -webkit-font-smoothing:antialiased;
transition:background .25s ease, color .25s ease;
}
img,svg{ max-width:100%; display:block; }
h1,h2,h3,h4{ font-family:var(--display); font-weight:600; line-height:1.15; margin:0 0 .6em; }
h1{ font-size:clamp(2.1rem, 4.4vw + 1rem, 3.6rem); font-weight:700; }
h2{ font-size:clamp(1.5rem, 2.4vw + 1rem, 2.2rem); }
h3{ font-size:clamp(1.1rem, 1.2vw + .8rem, 1.35rem); }
p{ margin:0 0 1em; color:var(--ink-soft); max-width:65ch; }
a{ color:var(--link); text-decoration-thickness:2px; text-underline-offset:.18em; }
a.hyperlink:visited{ color:var(--visited); }
a:hover{ text-decoration-style:wavy; }
:focus-visible{ outline:3px solid var(--focus); outline-offset:2px; border-radius:4px; }
code, pre, kbd{ font-family:var(--mono); }
code{ background:var(--line); padding:.15em .4em; border-radius:5px; font-size:.88em; }
.wrap{ width:min(1180px, 92%); margin-inline:auto; }
.visually-hidden{ position:absolute; width:1px; height:1px; overflow:hidden; clip:rect(0 0 0 0); white-space:nowrap; }
.skip-link{
position:absolute; left:1rem; top:-4rem; background:var(--ink); color:var(--paper);
padding:.7em 1.1em; border-radius:8px; z-index:200; transition:top .2s ease;
}
.skip-link:focus{ top:1rem; }
/* ====== HEADER ====== */
.site-header{
position:sticky; top:0; z-index:100; background:color-mix(in srgb, var(--paper) 88%, transparent);
backdrop-filter:blur(10px); border-bottom:1px solid var(--line);
}
.header-inner{
display:flex; align-items:center; justify-content:space-between; gap:1rem;
min-height:var(--header-h); flex-wrap:wrap; padding-block:.4rem;
}
.logo{ font-family:var(--display); font-weight:700; font-size:1.15rem; color:var(--ink); text-decoration:none; display:flex; align-items:center; gap:.4rem; }
.logo .tag{ color:var(--link); }
.logo .status{ font-family:var(--mono); font-size:.6rem; font-weight:500; color:var(--ok); border:1px solid currentColor; border-radius:999px; padding:.15em .5em; margin-left:.35rem; }
.header-controls{ display:flex; align-items:center; gap:.5rem; }
.icon-btn{
background:transparent; border:1.5px solid var(--line); color:var(--ink); border-radius:8px;
width:2.4rem; height:2.4rem; display:grid; place-items:center; cursor:pointer; font-size:1rem;
}
.icon-btn:hover{ border-color:var(--link); }
nav.site-nav{ width:100%; order:3; }
nav.site-nav ul{ list-style:none; margin:0; padding:.75rem 0; display:none; flex-direction:column; gap:.15rem; }
nav.site-nav.is-open ul{ display:flex; }
nav.site-nav a{
display:block; padding:.55em .3em; color:var(--ink); text-decoration:none; font-family:var(--display);
font-size:.95rem; font-weight:500; border-bottom:1px solid var(--line);
}
nav.site-nav a:hover{ color:var(--link); }
@media (min-width:800px){
nav.site-nav{ width:auto; order:2; }
nav.site-nav ul{ display:flex !important; flex-direction:row; padding:0; gap:1.3rem; }
nav.site-nav a{ border-bottom:none; padding:.3em .1em; position:relative; }
nav.site-nav a::after{ content:""; position:absolute; left:0; right:100%; bottom:-3px; height:2px; background:var(--link); transition:right .18s ease; }
nav.site-nav a:hover::after{ right:0; }
#nav-toggle{ display:none; }
}
/* ====== HERO ====== */
.hero{ padding-block:clamp(2.5rem, 6vw, 5rem); }
.hero-inner{ display:flex; flex-direction:column; gap:2.5rem; }
@media (min-width:960px){ .hero-inner{ flex-direction:row; align-items:center; } .hero-text{ flex:1 1 52%; } .hero-demo{ flex:1 1 40%; } }
.eyebrow{
display:inline-flex; align-items:center; gap:.5em; font-family:var(--mono); font-size:.78rem;
letter-spacing:.06em; text-transform:uppercase; color:var(--link); margin-bottom:1rem;
}
.eyebrow::before{ content:""; width:.5em; height:.5em; border-radius:50%; background:var(--ok); box-shadow:0 0 0 3px color-mix(in srgb, var(--ok) 25%, transparent); }
h1 .bracket{ color:var(--link); }
h1 .bracket.close{ color:var(--visited); }
.hero-stats{ display:flex; flex-wrap:wrap; gap:1.4rem 2rem; margin-top:1.6rem; }
.stat b{ display:block; font-family:var(--display); font-size:1.5rem; color:var(--ink); }
.stat span{ font-size:.82rem; color:var(--ink-soft); }
.hero-demo{
background:var(--paper-raised); border:1px solid var(--line); border-radius:16px; padding:1.5rem;
box-shadow:0 20px 45px -25px rgba(0,0,0,.35);
}
.hero-demo header{ display:flex; align-items:center; gap:.5rem; margin-bottom:1rem; }
.dot{ width:.6rem; height:.6rem; border-radius:50%; }
.dot.r{ background:#e05b52; } .dot.y{ background:#e5b93f; } .dot.g{ background:#3bb273; }
.hero-demo-label{ font-family:var(--mono); font-size:.75rem; color:var(--ink-soft); margin-left:auto; }
/* ====== BUTTONS ====== */
.btn{
font-family:var(--display); font-weight:600; font-size:.92rem; border-radius:9px;
border:2px solid var(--ink); background:var(--ink); color:var(--paper); padding:.65em 1.2em;
cursor:pointer; display:inline-flex; align-items:center; gap:.5em; transition:transform .12s ease, background .12s ease;
}
.btn:hover{ transform:translateY(-2px); }
.btn:active{ transform:translateY(0); }
.btn.ghost{ background:transparent; color:var(--ink); }
.btn.danger{ border-color:var(--danger); background:var(--danger); color:#fff; }
.btn.small{ padding:.45em .85em; font-size:.8rem; }
.btn:disabled{ opacity:.6; cursor:default; transform:none; }
/* ====== SECTIONS ====== */
section{ padding-block:clamp(3rem, 6vw, 5.5rem); scroll-margin-top:var(--header-h); border-top:1px solid var(--line); }
section#concepts{ border-top:none; }
.section-head{ max-width:60ch; margin-bottom:2.5rem; }
.section-num{ font-family:var(--mono); color:var(--link); font-size:.85rem; }
.grid{ display:flex; flex-wrap:wrap; gap:1.4rem; }
.grid > *{ flex:1 1 260px; }
.card{
background:var(--paper-raised); border:1px solid var(--line); border-radius:var(--radius);
padding:1.3rem 1.4rem; display:flex; flex-direction:column; gap:.5rem;
}
.card h3{ margin-bottom:.2rem; }
.card p{ margin-bottom:0; font-size:.94rem; }
.pill{
display:inline-block; font-family:var(--mono); font-size:.7rem; padding:.2em .6em; border-radius:999px;
border:1px solid var(--line); color:var(--ink-soft); align-self:flex-start;
}
/* diagram */
.diagram-figure{ margin:2rem 0 0; }
.diagram-figure figcaption{ font-size:.85rem; color:var(--ink-soft); margin-top:.6rem; text-align:center; }
/* table */
.table-scroll{ overflow-x:auto; border:1px solid var(--line); border-radius:var(--radius); }
table{ width:100%; border-collapse:collapse; min-width:620px; font-size:.92rem; }
caption{ text-align:left; font-family:var(--display); font-weight:600; padding:.9rem 1rem 0; }
th,td{ text-align:left; padding:.75rem 1rem; border-bottom:1px solid var(--line); vertical-align:top; }
thead th{ font-family:var(--display); background:color-mix(in srgb, var(--link) 8%, transparent); }
tbody tr:last-child td{ border-bottom:none; }
td code, th code{ white-space:nowrap; }
/* code blocks */
pre{
background:var(--code-bg); color:var(--code-fg); padding:1rem 1.1rem; border-radius:10px;
overflow-x:auto; font-size:.82rem; line-height:1.6; margin:0;
}
pre code{ background:none; padding:0; color:inherit; font-size:1em; }
.tk-tag{ color:var(--code-tag); } .tk-attr{ color:var(--code-attr); } .tk-str{ color:var(--code-string); } .tk-cmt{ color:var(--code-comment); font-style:italic; }
details.code-toggle{ margin-top:.4rem; }
details.code-toggle summary{
cursor:pointer; font-family:var(--mono); font-size:.78rem; color:var(--link); list-style:none;
display:flex; align-items:center; gap:.4em; padding:.3em 0;
}
details.code-toggle summary::-webkit-details-marker{ display:none; }
details.code-toggle summary::before{ content:"▸"; transition:transform .15s ease; }
details.code-toggle[open] summary::before{ transform:rotate(90deg); }
details.code-toggle pre{ margin-top:.6rem; }
/* demo cards */
.demo-card{
background:var(--paper-raised); border:1px solid var(--line); border-radius:var(--radius);
padding:1.4rem; display:flex; flex-direction:column; gap:.9rem;
}
.demo-card h3{ display:flex; align-items:center; gap:.5em; margin-bottom:0; }
.demo-card h3 .num{ font-family:var(--mono); color:var(--link); font-size:.85em; }
.field{ display:flex; flex-direction:column; gap:.35rem; }
.field label{ font-family:var(--display); font-size:.85rem; font-weight:600; }
input[type=search], input[type=email], input[type=text]{
font-family:var(--serif); font-size:1rem; padding:.6em .8em; border-radius:8px; border:1.5px solid var(--line);
background:var(--paper); color:var(--ink);
}
input:focus-visible{ border-color:var(--link); }
ul.result-list{ list-style:none; margin:0; padding:0; display:flex; flex-direction:column; gap:.4rem; }
ul.result-list li{ padding:.55em .7em; background:var(--paper); border-radius:7px; font-size:.92rem; border:1px solid var(--line); }
.htmx-indicator{ opacity:0; transition:opacity .2s ease; }
.htmx-request.htmx-indicator, .htmx-request .htmx-indicator{ opacity:1; }
.spinner{
width:1.1rem; height:1.1rem; border-radius:50%; border:2.5px solid var(--line); border-top-color:var(--link);
animation:spin .7s linear infinite; display:inline-block;
}
@keyframes spin{ to{ transform:rotate(360deg); } }
.feedback{ font-size:.88rem; padding:.5em .7em; border-radius:7px; }
.feedback.ok{ background:color-mix(in srgb, var(--ok) 15%, transparent); color:var(--ok); }
.feedback.err{ background:color-mix(in srgb, var(--danger) 15%, transparent); color:var(--danger); }
.progress-track{ width:100%; height:.6rem; background:var(--line); border-radius:999px; overflow:hidden; }
.progress-fill{ height:100%; background:var(--link); transition:width .5s ease; }
#toast-region{ position:fixed; bottom:1.2rem; right:1.2rem; z-index:150; display:flex; flex-direction:column; gap:.5rem; }
.toast{
background:var(--ink); color:var(--paper); padding:.7em 1.1em; border-radius:9px; font-size:.88rem;
box-shadow:0 12px 30px -10px rgba(0,0,0,.4); animation:toast-in .25s ease;
}
@keyframes toast-in{ from{ opacity:0; transform:translateY(8px); } to{ opacity:1; transform:translateY(0); } }
ul.task-list{ list-style:none; margin:0; padding:0; display:flex; flex-direction:column; gap:.5rem; }
ul.task-list li{ display:flex; align-items:center; justify-content:space-between; gap:1rem; padding:.6em .8em; border:1px solid var(--line); border-radius:8px; font-size:.92rem; }
/* overflow menu demo */
.menu-wrap{ position:relative; display:inline-block; }
.menu-panel{
position:absolute; top:calc(100% + .4rem); left:0; background:var(--paper-raised); border:1px solid var(--line);
border-radius:9px; padding:.4rem; min-width:170px; box-shadow:0 16px 34px -18px rgba(0,0,0,.4); z-index:20;
}
.menu-panel ul{ list-style:none; margin:0; padding:0; display:flex; flex-direction:column; }
.menu-panel button{
all:unset; padding:.55em .7em; border-radius:6px; cursor:pointer; font-size:.9rem; font-family:var(--serif); color:var(--ink);
}
.menu-panel button:hover, .menu-panel button:focus-visible{ background:var(--paper); }
/* tabs */
.tabs .tablist{ display:flex; flex-wrap:wrap; gap:.4rem; border-bottom:1px solid var(--line); margin-bottom:1rem; }
.tabs [role=tab]{
font-family:var(--display); font-size:.85rem; font-weight:600; background:transparent; border:none;
padding:.6em 1em; cursor:pointer; color:var(--ink-soft); border-bottom:2px solid transparent; margin-bottom:-1px;
}
.tabs [role=tab][aria-selected="true"]{ color:var(--link); border-bottom-color:var(--link); }
.tabs [role=tabpanel]{ display:none; }
.tabs [role=tabpanel].is-active{ display:block; }
/* attribute reference */
dl.attr-ref{ display:flex; flex-direction:column; gap:1rem; margin:0; }
dl.attr-ref div{ border-left:3px solid var(--link); padding-left:1rem; }
dl.attr-ref dt{ font-family:var(--mono); font-weight:700; }
dl.attr-ref dd{ margin:.25rem 0 0; color:var(--ink-soft); font-size:.92rem; }
footer{
background:var(--ink); color:var(--paper); padding-block:3rem;
}
footer a{ color:var(--paper); }
footer .foot-grid{ display:flex; flex-wrap:wrap; gap:2.5rem; }
footer .foot-grid > *{ flex:1 1 220px; }
footer h4{ color:var(--paper); font-size:1rem; }
footer ul{ list-style:none; margin:0; padding:0; display:flex; flex-direction:column; gap:.5rem; font-size:.9rem; }
.foot-bottom{ margin-top:2.5rem; padding-top:1.5rem; border-top:1px solid rgba(255,255,255,.15); font-size:.8rem; color:#aab3b7; display:flex; flex-wrap:wrap; gap:1rem; justify-content:space-between; }
/* two-col responsive helper */
.two-col{ display:flex; flex-direction:column; gap:2rem; }
@media (min-width:860px){ .two-col{ flex-direction:row; align-items:flex-start; } .two-col > *{ flex:1 1 0; } }
</style>
</head>
<body>
<a class="skip-link" href="#contenu">Aller au contenu principal</a>
<header class="site-header">
<div class="wrap header-inner">
<a class="logo" href="#top">
<span class="tag"><</span>hypermedia<span class="tag">.be</span><span class="tag">/></span>
<span class="status" title="Bibliothèque htmx chargée sur cette page">htmx 2.0.10</span>
</a>
<div class="header-controls">
<button class="icon-btn" id="theme-toggle" type="button" aria-pressed="false" aria-label="Activer le thème sombre">
<span aria-hidden="true" id="theme-icon">☾</span>
</button>
<button class="icon-btn" id="nav-toggle" type="button" aria-expanded="false" aria-controls="site-nav" aria-label="Ouvrir le menu de navigation">
<span aria-hidden="true">☰</span>
</button>
</div>
<nav class="site-nav" id="site-nav" aria-label="Navigation principale">
<ul>
<li><a href="#concepts">Concepts</a></li>
<li><a href="#htmx">HTMX</a></li>
<li><a href="#scripting">Scripting</a></li>
<li><a href="#apis">APIs</a></li>
<li><a href="#mobile">Mobile</a></li>
<li><a href="#ressources">Ressources</a></li>
</ul>
</nav>
</div>
</header>
<main id="contenu">
<!-- ============ HERO ============ -->
<section id="top" class="hero" aria-labelledby="hero-h">
<div class="wrap hero-inner">
<div class="hero-text">
<p class="eyebrow">Guide interactif — condensé de « Hypermedia Systems »</p>
<h1 id="hero-h"><span class="bracket"><html></span> pensé comme un hypermédia<span class="bracket close"></html></span></h1>
<p style="font-size:1.08rem;max-width:56ch;">hypermedia.be rassemble, en un seul endroit et en français, l'essentiel du livre de référence sur htmx et l'architecture hypermédia : les concepts REST et HATEOAS, les attributs qui comptent vraiment, et des démonstrations qui tournent réellement dans cette page — sans build, sans framework JS côté client.</p>
<div style="display:flex;gap:.9rem;flex-wrap:wrap;margin-top:1.4rem;">
<a class="btn" href="#htmx">Voir les démos htmx</a>
<a class="btn ghost" href="#concepts">Comprendre les concepts</a>
</div>
<div class="hero-stats">
<div class="stat"><b>15</b><span>chapitres condensés</span></div>
<div class="stat"><b>~14 Ko</b><span>htmx minifié + gzippé</span></div>
<div class="stat"><b>0</b><span>étape de build</span></div>
<div class="stat"><b>3</b><span>approches de scripting comparées</span></div>
</div>
</div>
<div class="hero-demo">
<header>
<span class="dot r" aria-hidden="true"></span><span class="dot y" aria-hidden="true"></span><span class="dot g" aria-hidden="true"></span>
<span class="hero-demo-label">démo live — hx-post</span>
</header>
<p style="font-size:.9rem;">Ce bouton n'exécute aucun JavaScript personnalisé : l'attribut <code>hx-post</code> déclenche seul une requête, et htmx remplace le bouton par la réponse HTML reçue.</p>
<button id="hero-counter" class="btn" hx-post="/demo/click" hx-swap="outerHTML" hx-target="this">
Cliquez ici <span aria-hidden="true">→</span>
</button>
<details class="code-toggle">
<summary>Voir le HTML source</summary>
<pre><code><span class="tk-tag"><button</span> <span class="tk-attr">hx-post</span>=<span class="tk-str">"/clicked"</span> <span class="tk-attr">hx-swap</span>=<span class="tk-str">"outerHTML"</span><span class="tk-tag">></span>
Cliquez ici
<span class="tk-tag"></button></span></code></pre>
</details>
</div>
</div>
</section>
<!-- ============ CONCEPTS ============ -->
<section id="concepts" aria-labelledby="concepts-h">
<div class="wrap">
<div class="section-head">
<p class="section-num">I. Concepts</p>
<h2 id="concepts-h">Penser en hypermédia avant de penser en JavaScript</h2>
<p>Un système hypermédia laisse le serveur décrire, à chaque réponse, les actions possibles sous forme de liens et de formulaires. Le client n'a besoin de connaître qu'une poignée de règles : suivre un lien, soumettre un formulaire, afficher ce qu'on lui donne. htmx ne fait qu'étendre ce principe à des éléments qui, historiquement, ne pouvaient pas déclencher de requêtes.</p>
</div>
<div class="grid" style="margin-bottom:2.5rem;">
<div class="card">
<span class="pill">HTML</span>
<h3>La première vraie hypermédia</h3>
<p>Les balises <code><a></code> et <code><form></code> encodent déjà tout ce qu'il faut : une action, une méthode, une destination. htmx ne fait qu'ouvrir cette capacité au clic sur un <code><div></code>, à la frappe dans un champ, ou à un défilement.</p>
</div>
<div class="card">
<span class="pill">REST</span>
<h3>Quatre contraintes, pas un format JSON</h3>
<p>Client-serveur, absence d'état côté serveur entre requêtes, mise en cache, et une interface uniforme où les réponses contiennent elles-mêmes les liens vers les actions suivantes (HATEOAS).</p>
</div>
<div class="card">
<span class="pill">Architecture</span>
<h3>Un SPA sans le poids d'un SPA</h3>
<p>Le rendu reste sur le serveur ; le client ne gère qu'un échange local de fragments. On garde la réactivité perçue d'une application monopage sans dupliquer la logique de vue côté client.</p>
</div>
</div>
<figure class="diagram-figure">
<svg viewbox="0 0 900 220" role="img" aria-labelledby="diagram-title diagram-desc" style="max-width:900px;margin-inline:auto;">
<title id="diagram-title">Cycle d'une requête hypermédia</title>
<desc id="diagram-desc">Le navigateur envoie une requête déclenchée par un attribut htmx ; le serveur répond avec un fragment HTML ; htmx échange ce fragment dans la page sans rechargement complet.</desc>
<defs>
<marker id="arrow" viewbox="0 0 10 10" refx="8" refy="5" markerwidth="6" markerheight="6" orient="auto-start-reverse">
<path d="M0,0 L10,5 L0,10 z" fill="var(--link)"></path>
</marker>
</defs>
<g font-family="JetBrains Mono, monospace" font-size="13">
<rect x="30" y="60" width="190" height="100" rx="12" fill="none" stroke="var(--ink)" stroke-width="2"></rect>
<text x="125" y="105" text-anchor="middle" fill="var(--ink)" font-weight="700">Navigateur</text>
<text x="125" y="128" text-anchor="middle" fill="var(--ink-soft)" font-size="11">hx-get / hx-post…</text>
<line x1="230" y1="90" x2="450" y2="90" stroke="var(--link)" stroke-width="2" marker-end="url(#arrow)"></line>
<text x="340" y="80" text-anchor="middle" fill="var(--link)" font-size="11">requête HTTP</text>
<rect x="450" y="60" width="200" height="100" rx="12" fill="none" stroke="var(--ink)" stroke-width="2"></rect>
<text x="550" y="105" text-anchor="middle" fill="var(--ink)" font-weight="700">Serveur</text>
<text x="550" y="128" text-anchor="middle" fill="var(--ink-soft)" font-size="11">rend un fragment HTML</text>
<line x1="450" y1="130" x2="230" y2="130" stroke="var(--visited)" stroke-width="2" marker-end="url(#arrow)"></line>
<text x="340" y="150" text-anchor="middle" fill="var(--visited)" font-size="11">fragment HTML</text>
<rect x="700" y="60" width="170" height="100" rx="12" fill="none" stroke="var(--ok)" stroke-width="2" stroke-dasharray="5 4"></rect>
<text x="785" y="105" text-anchor="middle" fill="var(--ok)" font-weight="700" font-size="12">hx-swap</text>
<text x="785" y="128" text-anchor="middle" fill="var(--ink-soft)" font-size="11">échange le DOM</text>
<line x1="650" y1="100" x2="700" y2="100" stroke="var(--ok)" stroke-width="2" marker-end="url(#arrow)"></line>
</g>
</svg>
<figcaption>Le serveur reste seul responsable du rendu : le client n'exécute qu'un échange de fragment.</figcaption>
</figure>
<div class="two-col" style="margin-top:2.5rem;">
<div class="card">
<h3>Quand privilégier l'hypermédia</h3>
<p style="margin-bottom:.6rem;">Sites de contenu, back-offices, tableaux de bord internes, formulaires métier, applications où le serveur détient déjà la vérité sur les données.</p>
</div>
<div class="card">
<h3>Quand s'en écarter</h3>
<p style="margin-bottom:.6rem;">Éditeurs collaboratifs temps réel, jeux, applications hors-ligne complexes, ou tout état client si volumineux qu'il ne peut raisonnablement transiter à chaque requête.</p>
</div>
</div>
</div>
</section>
<!-- ============ HTMX ============ -->
<section id="htmx" aria-labelledby="htmx-h">
<div class="wrap">
<div class="section-head">
<p class="section-num">II. HTMX en pratique</p>
<h2 id="htmx-h">Les attributs, puis les démonstrations</h2>
<p>htmx ajoute une douzaine d'attributs qui suffisent à couvrir l'essentiel des interactions d'une application : déclenchement, ciblage, échange, historique. Chaque démo ci-dessous exécute réellement ces attributs dans votre navigateur.</p>
</div>
<div class="table-scroll" style="margin-bottom:3rem;">
<table>
<caption>Référence rapide des attributs les plus utilisés</caption>
<thead>
<tr><th scope="col">Attribut</th><th scope="col">Rôle</th></tr>
</thead>
<tbody>
<tr><td><code>hx-get / hx-post / hx-put / hx-patch / hx-delete</code></td><td>Associe une méthode HTTP et une URL à n'importe quel élément, pas seulement <code><a></code> ou <code><form></code>.</td></tr>
<tr><td><code>hx-trigger</code></td><td>Choisit l'événement déclencheur : <code>click</code>, <code>keyup changed delay:300ms</code>, <code>every 2s</code>, <code>revealed</code>…</td></tr>
<tr><td><code>hx-target</code></td><td>Désigne l'élément qui recevra la réponse, via un sélecteur CSS ou des mots-clés comme <code>this</code> ou <code>closest tr</code>.</td></tr>
<tr><td><code>hx-swap</code></td><td>Définit comment la réponse est insérée : <code>innerHTML</code>, <code>outerHTML</code>, <code>beforeend</code>, <code>delete</code>…</td></tr>
<tr><td><code>hx-swap-oob</code></td><td>Permet à la réponse de mettre à jour un second endroit de la page, hors de la cible principale.</td></tr>
<tr><td><code>hx-boost</code></td><td>Transforme discrètement les liens et formulaires existants en requêtes AJAX, sans changer le balisage.</td></tr>
<tr><td><code>hx-indicator</code></td><td>Affiche un élément (spinner, barre) pendant la durée de la requête.</td></tr>
<tr><td><code>hx-confirm</code></td><td>Demande une confirmation native avant d'envoyer la requête.</td></tr>
<tr><td><code>hx-push-url</code></td><td>Met à jour l'URL et l'historique du navigateur après l'échange.</td></tr>
</tbody>
</table>
</div>
<h3 style="margin-bottom:1.5rem;">Démonstrations live</h3>
<div class="grid">
<!-- Demo: recherche active -->
<article class="demo-card" style="flex-basis:100%;">
<h3><span class="num">01</span> Recherche active (hx-trigger avec debounce)</h3>
<p style="margin-bottom:0;">Tapez dans le champ : chaque frappe attend 300 ms d'inactivité avant d'interroger le serveur, évitant une requête par lettre.</p>
<div class="field">
<label for="search-input">Rechercher un article (essayez « swap », « rest », « boost »)</label>
<div style="display:flex;align-items:center;gap:.6rem;">
<input type="search" id="search-input" name="q" placeholder="Tapez pour chercher…"
hx-get="/demo/search" hx-trigger="keyup changed delay:300ms, search" hx-target="#search-results" hx-indicator="#search-spinner" autocomplete="off" style="flex:1;">
<span id="search-spinner" class="htmx-indicator spinner" role="status" aria-label="Recherche en cours"></span>
</div>
</div>
<ul class="result-list" id="search-results" aria-live="polite">
<li>Tapez au moins une lettre pour lancer la recherche.</li>
</ul>
<details class="code-toggle">
<summary>Voir le HTML source</summary>
<pre><code><span class="tk-tag"><input</span> <span class="tk-attr">type</span>=<span class="tk-str">"search"</span>
<span class="tk-attr">hx-get</span>=<span class="tk-str">"/search"</span>
<span class="tk-attr">hx-trigger</span>=<span class="tk-str">"keyup changed delay:300ms, search"</span>
<span class="tk-attr">hx-target</span>=<span class="tk-str">"#search-results"</span>
<span class="tk-attr">hx-indicator</span>=<span class="tk-str">"#search-spinner"</span><span class="tk-tag">></span></code></pre>
</details>
</article>
<!-- Demo: validation email -->
<article class="demo-card">
<h3><span class="num">02</span> Validation d'email inline</h3>
<p style="margin-bottom:0;">La validation se fait côté serveur, à chaque changement : c'est la même logique qui validera à la soumission finale.</p>
<div class="field">
<label for="email-input">Adresse email</label>
<input type="email" id="email-input" name="email" placeholder="vous@exemple.be"
hx-post="/demo/validate-email" hx-trigger="keyup changed delay:400ms, blur" hx-target="#email-feedback" hx-swap="innerHTML">
</div>
<div id="email-feedback" aria-live="polite"></div>
<details class="code-toggle">
<summary>Voir le HTML source</summary>
<pre><code><span class="tk-tag"><input</span> <span class="tk-attr">type</span>=<span class="tk-str">"email"</span>
<span class="tk-attr">hx-post</span>=<span class="tk-str">"/validate-email"</span>
<span class="tk-attr">hx-trigger</span>=<span class="tk-str">"keyup changed delay:400ms, blur"</span>
<span class="tk-attr">hx-target</span>=<span class="tk-str">"#email-feedback"</span><span class="tk-tag">></span></code></pre>
</details>
</article>
<!-- Demo: charger plus -->
<article class="demo-card">
<h3><span class="num">03</span> Pagination « charger plus »</h3>
<p style="margin-bottom:0;">Le bouton se remplace lui-même par la réponse suivante, jusqu'à ce que le serveur cesse de renvoyer de bouton.</p>
<ul class="result-list" id="items-list">
<li>Article — Introduction à hx-boost</li>
<li>Article — REST et HATEOAS en pratique</li>
</ul>
<button class="btn small" hx-get="/demo/more?page=2" hx-target="this" hx-swap="outerHTML">Charger plus ↓</button>
<details class="code-toggle">
<summary>Voir le HTML source</summary>
<pre><code><span class="tk-tag"><button</span>
<span class="tk-attr">hx-get</span>=<span class="tk-str">"/contacts?page=2"</span>
<span class="tk-attr">hx-target</span>=<span class="tk-str">"this"</span>
<span class="tk-attr">hx-swap</span>=<span class="tk-str">"outerHTML"</span><span class="tk-tag">></span>
Charger plus
<span class="tk-tag"></button></span></code></pre>
</details>
</article>
<!-- Demo: polling -->
<article class="demo-card">
<h3><span class="num">04</span> Sondage de progression (polling)</h3>
<p style="margin-bottom:0;">Après lancement, l'élément se ré-interroge lui-même toutes les 800 ms grâce à <code>hx-trigger="every 800ms"</code>, jusqu'à ce que le serveur retire ce déclencheur de la réponse.</p>
<button class="btn small" hx-post="/demo/start-job" hx-target="#job-status" hx-swap="innerHTML">Lancer le traitement</button>
<div id="job-status" aria-live="polite"></div>
<details class="code-toggle">
<summary>Voir le HTML source</summary>
<pre><code><span class="tk-cmt"><!-- fragment renvoyé tant que le job tourne --></span>
<span class="tk-tag"><div</span>
<span class="tk-attr">hx-get</span>=<span class="tk-str">"/job-status"</span>
<span class="tk-attr">hx-trigger</span>=<span class="tk-str">"load, every 800ms"</span>
<span class="tk-attr">hx-swap</span>=<span class="tk-str">"outerHTML"</span><span class="tk-tag">></span>…<span class="tk-tag"></div></span></code></pre>
</details>
</article>
<!-- Demo: suppression + oob -->
<article class="demo-card" style="flex-basis:100%;">
<h3><span class="num">05</span> Suppression inline & mise à jour hors-cible (<code>hx-swap-oob</code>)</h3>
<p style="margin-bottom:0;">Supprimer une tâche remplace la ligne par une réponse vide <em>et</em> fait apparaître une notification ailleurs sur la page — dans la même réponse HTTP.</p>
<ul class="task-list" id="task-list">
<li id="task-1">Rédiger la documentation htmx <button class="btn small danger" hx-delete="/demo/task/1" hx-target="#task-1" hx-swap="outerHTML" hx-confirm="Supprimer « Rédiger la documentation htmx » ?">Supprimer</button></li>
<li id="task-2">Migrer le back-office vers hx-boost <button class="btn small danger" hx-delete="/demo/task/2" hx-target="#task-2" hx-swap="outerHTML" hx-confirm="Supprimer « Migrer le back-office vers hx-boost » ?">Supprimer</button></li>
<li id="task-3">Ajouter un indicateur de chargement <button class="btn small danger" hx-delete="/demo/task/3" hx-target="#task-3" hx-swap="outerHTML" hx-confirm="Supprimer « Ajouter un indicateur de chargement » ?">Supprimer</button></li>
</ul>
<details class="code-toggle">
<summary>Voir le HTML source (réponse du serveur)</summary>
<pre><code><span class="tk-cmt"><!-- corps de la réponse à hx-delete : vide pour la cible, --></span>
<span class="tk-cmt"><!-- + un swap "out of band" pour la notification --></span>
<span class="tk-tag"><div</span> <span class="tk-attr">id</span>=<span class="tk-str">"toast-region"</span> <span class="tk-attr">hx-swap-oob</span>=<span class="tk-str">"true"</span><span class="tk-tag">></span>
<span class="tk-tag"><div</span> <span class="tk-attr">class</span>=<span class="tk-str">"toast"</span><span class="tk-tag">></span>Tâche supprimée ✓<span class="tk-tag"></div></span>
<span class="tk-tag"></div></span></code></pre>
</details>
</article>
</div>
<div class="two-col" style="margin-top:3rem;">
<div class="card">
<h3>hx-boost : l'amélioration progressive</h3>
<p>Posé sur un conteneur, <code>hx-boost="true"</code> intercepte tous les liens et formulaires enfants pour les transformer en requêtes AJAX, tout en conservant l'historique et le fonctionnement sans JavaScript comme filet de sécurité.</p>
<pre><code><span class="tk-tag"><body</span> <span class="tk-attr">hx-boost</span>=<span class="tk-str">"true"</span><span class="tk-tag">></span>
<span class="tk-cmt"><!-- tous les <a> et <form> deviennent AJAX --></span>
<span class="tk-tag"></body></span></code></pre>
</div>
<div class="card">
<h3>Codes HTTP & événements</h3>
<p>htmx traite nativement les codes 2xx comme un succès et déclenche des événements JavaScript (<code>htmx:beforeRequest</code>, <code>htmx:afterSwap</code>, <code>htmx:responseError</code>…) sur lesquels du vanilla JS peut se brancher pour journaliser, animer ou déboguer.</p>
<pre><code>document.body.addEventListener(<span class="tk-str">'htmx:afterSwap'</span>, (e) => {
console.log(<span class="tk-str">'Échange terminé sur'</span>, e.detail.target);
});</code></pre>
</div>
</div>
</div>
</section>
<!-- ============ SCRIPTING ============ -->
<section id="scripting" aria-labelledby="scripting-h">
<div class="wrap">
<div class="section-head">
<p class="section-num">III. Scripting pragmatique</p>
<h2 id="scripting-h">htmx couvre le réseau, pas l'interaction locale</h2>
<p>Un menu qui s'ouvre, une valeur qui se recalcule à l'écran : ce sont des comportements purement locaux, sans requête HTTP. htmx les laisse volontairement de côté. Trois approches courantes coexistent — le vanilla JS reste la référence utilisée sur cette page.</p>
</div>
<div class="demo-card" style="margin-bottom:2.5rem;">
<h3><span class="num">Démo</span> Menu « overflow » en vanilla JS pur</h3>
<p style="margin-bottom:0;">Un bouton révèle un menu, se ferme au clic extérieur ou avec <kbd>Échap</kbd>, et gère <code>aria-expanded</code> — le tout en une douzaine de lignes de JavaScript, sans dépendance.</p>
<div class="menu-wrap">
<button class="btn ghost small" id="menu-btn" type="button" aria-haspopup="true" aria-expanded="false" aria-controls="menu-panel">Actions ⋯</button>
<div class="menu-panel" id="menu-panel" hidden>
<ul>
<li><button type="button" id="menu-action-1">Dupliquer</button></li>
<li><button type="button" id="menu-action-2">Archiver</button></li>
<li><button type="button" id="menu-action-3">Exporter en HTML</button></li>
</ul>
</div>
</div>
<p id="menu-feedback" style="font-size:.85rem;color:var(--ink-soft);margin:0;" aria-live="polite"></p>
</div>
<div class="tabs">
<div class="tablist" role="tablist" aria-label="Comparaison des approches de scripting">
<button role="tab" id="tab-vanilla" aria-controls="panel-vanilla" aria-selected="true">Vanilla JS</button>
<button role="tab" id="tab-alpine" aria-controls="panel-alpine" aria-selected="false" tabindex="-1">Alpine.js</button>
<button role="tab" id="tab-hyperscript" aria-controls="panel-hyperscript" aria-selected="false" tabindex="-1">_hyperscript</button>
</div>
<div role="tabpanel" id="panel-vanilla" aria-labelledby="tab-vanilla" class="is-active">
<p>La cible de référence : disponible partout, sans dépendance, respecte la « localité du comportement » quand le script reste proche du balisage qu'il pilote.</p>
<pre><code><span class="tk-tag"><button</span> <span class="tk-attr">id</span>=<span class="tk-str">"menu-btn"</span><span class="tk-tag">></span>Actions ⋯<span class="tk-tag"></button></span>
<span class="tk-cmt">// script séparé, proche du composant</span>
menuBtn.addEventListener(<span class="tk-str">'click'</span>, () => {
panel.hidden = !panel.hidden;
menuBtn.setAttribute(<span class="tk-str">'aria-expanded'</span>, !panel.hidden);
});</code></pre>
</div>
<div role="tabpanel" id="panel-alpine" aria-labelledby="tab-alpine" hidden>
<p>Alpine.js ramène l'état directement dans le balisage via <code>x-data</code>, au prix d'une petite bibliothèque supplémentaire chargée sur la page.</p>
<pre><code><span class="tk-tag"><div</span> <span class="tk-attr">x-data</span>=<span class="tk-str">"{ open: false }"</span><span class="tk-tag">></span>
<span class="tk-tag"><button</span> <span class="tk-attr">x-on:click</span>=<span class="tk-str">"open = ! open"</span><span class="tk-tag">></span>Actions ⋯<span class="tk-tag"></button></span>
<span class="tk-tag"><div</span> <span class="tk-attr">x-show</span>=<span class="tk-str">"open"</span><span class="tk-tag">></span>…<span class="tk-tag"></div></span>
<span class="tk-tag"></div></span></code></pre>
</div>
<div role="tabpanel" id="panel-hyperscript" aria-labelledby="tab-hyperscript" hidden>
<p>_hyperscript vise une syntaxe proche du langage naturel, directement dans un attribut, pour des interactions très locales.</p>
<pre><code><span class="tk-tag"><button</span> <span class="tk-attr">_</span>=<span class="tk-str">"on click toggle .hidden on #menu-panel"</span><span class="tk-tag">></span>
Actions ⋯
<span class="tk-tag"></button></span></code></pre>
</div>
</div>
</div>
</section>
<!-- ============ APIS ============ -->
<section id="apis" aria-labelledby="apis-h">
<div class="wrap">
<div class="section-head">
<p class="section-num">IV. API hypermédia vs API JSON</p>
<h2 id="apis-h">Deux API, deux publics, un seul serveur</h2>
<p>Rien n'empêche de servir en parallèle une API hypermédia pour l'interface web et une API JSON pour les intégrations tierces : elles répondent à des besoins différents et peuvent partager la même couche métier.</p>
</div>
<div class="table-scroll">
<table>
<caption class="visually-hidden">Comparaison API hypermédia et API JSON</caption>
<thead>
<tr><th scope="col">Critère</th><th scope="col">API hypermédia (HTML)</th><th scope="col">API JSON classique</th></tr>
</thead>
<tbody>
<tr><td>Format de réponse</td><td>Fragment HTML directement affichable</td><td>Données structurées, à interpréter côté client</td></tr>
<tr><td>Couplage client / serveur</td><td>Fort et assumé : le serveur pilote la vue</td><td>Faible : le client construit sa propre vue</td></tr>
<tr><td>Découvrabilité (HATEOAS)</td><td>Les actions suivantes sont dans la réponse</td><td>Généralement documentée à part (OpenAPI…)</td></tr>
<tr><td>Consommateur typique</td><td>L'interface web de l'application elle-même</td><td>Applications mobiles, intégrations tierces, partenaires</td></tr>
<tr><td>Évolution des champs</td><td>Peu de rupture : le HTML encapsule le détail</td><td>Toute évolution de forme peut casser des clients</td></tr>
</tbody>
</table>
</div>
</div>
</section>
<!-- ============ MOBILE ============ -->
<section id="mobile" aria-labelledby="mobile-h">
<div class="wrap">
<div class="section-head">
<p class="section-num">V. L'hypermédia sur mobile</p>
<h2 id="mobile-h">Hyperview : le même principe, en HXML natif</h2>
<p>Hyperview transpose l'idée d'htmx à une application mobile native : un format XML (HXML) décrit l'interface, un client React Native l'interprète, et le serveur reste seul maître de la logique et de l'état.</p>
</div>
<div class="grid">
<div class="card">
<h3>Le format</h3>
<p>Des éléments comme <code><list></code>, <code><image></code> ou <code><text></code> décrivent l'écran ; le style est séparé, proche d'une feuille CSS restreinte.</p>
</div>
<div class="card">
<h3>Les comportements</h3>
<p>Des déclencheurs comme <em>long-press</em>, <em>load</em>, <em>visible</em> ou <em>refresh</em> associent une action réseau à un geste, exactement comme <code>hx-trigger</code> côté web.</p>
</div>
<div class="card">
<h3>Le choix d'architecture</h3>
<p>Entre une WebView, une application 100% native en JavaScript, et Hyperview : ce dernier vise le point d'équilibre pour des équipes déjà outillées côté serveur hypermédia.</p>
</div>
</div>
</div>
</section>
<!-- ============ RESSOURCES ============ -->
<section id="ressources" aria-labelledby="ressources-h">
<div class="wrap">
<div class="section-head">
<p class="section-num">Pour aller plus loin</p>
<h2 id="ressources-h">Les sources originales</h2>
<p>Cette page condense et illustre ; les ressources ci-dessous font autorité.</p>
</div>
<ul class="grid" style="list-style:none;padding:0;margin:0;">
<li class="card"><h3><a class="hyperlink" href="https://htmx.org" target="_blank" rel="noopener">htmx.org</a></h3><p>Documentation officielle, référence complète des attributs et des extensions.</p></li>
<li class="card"><h3><a class="hyperlink" href="https://hypermedia.systems/book/contents/" target="_blank" rel="noopener">Hypermedia Systems</a></h3><p>Le livre condensé sur cette page, en accès libre.</p></li>
<li class="card"><h3><a class="hyperlink" href="https://github.com/bigskysoftware/htmx" target="_blank" rel="noopener">Dépôt GitHub</a></h3><p>Code source, changelog et suivi des versions d'htmx.</p></li>
<li class="card"><h3><a class="hyperlink" href="https://hyperview.org" target="_blank" rel="noopener">Hyperview.org</a></h3><p>L'hypermédia appliquée aux applications mobiles natives.</p></li>
</ul>
</div>
</section>
</main>
<footer>
<div class="wrap">
<div class="foot-grid">
<div>
<a class="logo" href="#top" style="color:var(--paper);"><hypermedia.be/></a>
<p style="color:#c7ced1;font-size:.9rem;margin-top:.8rem;">Un condensé indépendant, en français, des idées portées par htmx et « Hypermedia Systems ».</p>
</div>
<div>
<h4>Sections</h4>
<ul>
<li><a href="#concepts">Concepts</a></li>
<li><a href="#htmx">HTMX</a></li>
<li><a href="#scripting">Scripting</a></li>
<li><a href="#apis">APIs</a></li>
<li><a href="#mobile">Mobile</a></li>
</ul>
</div>
<div>
<h4>À propos des démos</h4>
<p style="color:#c7ced1;font-size:.85rem;"></p>
</div>
</div>
<div class="foot-bottom">
<span>© 2014 - 2026 hypermedia.be, une page pédagogique indépendante, sans aucune affiliation.</span>
<span></span>
</div>
</div>
</footer>
<div id="toast-region" aria-live="polite"></div>
<script>
(function(){
"use strict";
/* ---------- Thème clair / sombre (en mémoire, sans stockage navigateur) ---------- */
var themeBtn = document.getElementById('theme-toggle');
var themeIcon = document.getElementById('theme-icon');
var root = document.documentElement;
themeBtn.addEventListener('click', function(){
var dark = root.getAttribute('data-theme') === 'dark';
root.setAttribute('data-theme', dark ? 'light' : 'dark');
themeBtn.setAttribute('aria-pressed', String(!dark));
themeBtn.setAttribute('aria-label', dark ? 'Activer le thème sombre' : 'Activer le thème clair');
themeIcon.textContent = dark ? '☾' : '☀';
});
/* ---------- Menu de navigation mobile ---------- */
var navToggle = document.getElementById('nav-toggle');
var nav = document.getElementById('site-nav');
navToggle.addEventListener('click', function(){
var open = nav.classList.toggle('is-open');
navToggle.setAttribute('aria-expanded', String(open));
navToggle.setAttribute('aria-label', open ? 'Fermer le menu de navigation' : 'Ouvrir le menu de navigation');
});
nav.addEventListener('click', function(e){
if (e.target.tagName === 'A' && nav.classList.contains('is-open')) {
nav.classList.remove('is-open');
navToggle.setAttribute('aria-expanded', 'false');
}
});
/* ---------- Menu "overflow" — exemple vanilla JS du livre ---------- */
var menuBtn = document.getElementById('menu-btn');
var menuPanel = document.getElementById('menu-panel');
var menuFeedback = document.getElementById('menu-feedback');
function closeMenu(){
menuPanel.hidden = true;
menuBtn.setAttribute('aria-expanded', 'false');
}
function openMenu(){
menuPanel.hidden = false;
menuBtn.setAttribute('aria-expanded', 'true');
}
menuBtn.addEventListener('click', function(e){
e.stopPropagation();
if (menuPanel.hidden) openMenu(); else closeMenu();
});
document.addEventListener('click', function(e){
if (!menuPanel.hidden && !menuPanel.contains(e.target) && e.target !== menuBtn) closeMenu();
});
document.addEventListener('keydown', function(e){
if (e.key === 'Escape' && !menuPanel.hidden) { closeMenu(); menuBtn.focus(); }
});
['menu-action-1','menu-action-2','menu-action-3'].forEach(function(id){
document.getElementById(id).addEventListener('click', function(){
menuFeedback.textContent = 'Action « ' + this.textContent + ' » déclenchée localement (aucune requête réseau).';
closeMenu();
menuBtn.focus();
});
});
/* ---------- Onglets ARIA (comparaison scripting) ---------- */
var tabs = document.querySelectorAll('.tabs [role=tab]');
tabs.forEach(function(tab){
tab.addEventListener('click', function(){
tabs.forEach(function(t){
t.setAttribute('aria-selected', 'false');
t.tabIndex = -1;
document.getElementById(t.getAttribute('aria-controls')).hidden = true;
document.getElementById(t.getAttribute('aria-controls')).classList.remove('is-active');
});
tab.setAttribute('aria-selected', 'true');
tab.tabIndex = 0;
var panel = document.getElementById(tab.getAttribute('aria-controls'));
panel.hidden = false;
panel.classList.add('is-active');
});
tab.addEventListener('keydown', function(e){
var list = Array.prototype.slice.call(tabs);
var i = list.indexOf(tab);
if (e.key === 'ArrowRight') { e.preventDefault(); list[(i+1) % list.length].focus(); list[(i+1) % list.length].click(); }
if (e.key === 'ArrowLeft') { e.preventDefault(); list[(i-1+list.length) % list.length].focus(); list[(i-1+list.length) % list.length].click(); }
});
});
/* ---------- Toasts auto-effaçables (après un swap out-of-band) ---------- */
document.body.addEventListener('htmx:afterSwap', function(evt){
if (evt.target && evt.target.id === 'toast-region') {
setTimeout(function(){ evt.target.innerHTML = ''; }, 3200);
}
});
/* =====================================================================
SERVEUR SIMULÉ CÔTÉ CLIENT
Cette page est statique : pour que les démonstrations htmx restent
pleinement fonctionnelles hors ligne, un faux XMLHttpRequest (la même
technique utilisée dans de nombreux bacs à sable htmx) intercepte les
routes "/demo/*" et renvoie de vrais fragments HTML, avec un léger
délai réseau simulé. htmx ne voit aucune différence avec un vrai
serveur : c'est strictement la même API du navigateur.
===================================================================== */
var ARTICLES = [
{ titre: 'hx-boost : l’amélioration progressive en un attribut', tags: 'boost progressive' },
{ titre: 'hx-swap en détail : innerHTML, outerHTML, transitions', tags: 'swap transitions' },
{ titre: 'REST et HATEOAS, la théorie qui prend enfin sens en HTML', tags: 'rest hateoas' },
{ titre: 'hx-trigger : au-delà du clic, delay et debounce', tags: 'trigger debounce' },
{ titre: 'Server-Sent Events avec l’extension sse', tags: 'sse events' },
{ titre: 'WebSockets déclaratifs avec hx-ws', tags: 'websockets ws' },
{ titre: 'Content Security Policy et htmx', tags: 'securite csp' },
{ titre: 'hx-push-url et l’historique du navigateur', tags: 'boost historique' }
];
var jobProgress = 0;
var clickCount = 0;
function esc(s){
return String(s).replace(/[&<>"]/g, function(c){
return { '&':'&', '<':'<', '>':'>', '"':'"' }[c];
});
}
function qs(url, key){
var m = url.split('?')[1];
if (!m) return '';
var params = new URLSearchParams(m);
return params.get(key) || '';
}
var ROUTES = {
'POST /demo/click': function(){
clickCount++;
return { status: 200, body:
'<button id="hero-counter" class="btn" hx-post="/demo/click" hx-swap="outerHTML" hx-target="this">' +
(clickCount === 1 ? '1 requête envoyée' : clickCount + ' requêtes envoyées') +
' <span aria-hidden="true">→</span></button>' };
},
'GET /demo/search': function(url){
var q = qs(url, 'q').trim().toLowerCase();
if (!q) return { status: 200, body: '<li>Tapez au moins une lettre pour lancer la recherche.</li>' };
var found = ARTICLES.filter(function(a){
return a.titre.toLowerCase().indexOf(q) !== -1 || a.tags.indexOf(q) !== -1;
});
if (found.length === 0) return { status: 200, body: '<li>Aucun résultat pour «\u00a0' + esc(q) + '\u00a0».</li>' };
return { status: 200, body: found.map(function(a){ return '<li>' + esc(a.titre) + '</li>'; }).join('') };
},
'POST /demo/validate-email': function(url, body){
var params = new URLSearchParams(body || '');
var email = params.get('email') || '';
var valid = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/.test(email);
if (!email) return { status: 200, body: '' };
if (valid) return { status: 200, body: '<div class="feedback ok">✓ Adresse valide, le serveur l’accepterait.</div>' };
return { status: 200, body: '<div class="feedback err">✕ Format d’adresse invalide.</div>' };
},
'GET /demo/more': function(url){
var page = parseInt(qs(url, 'page') || '2', 10);
var extra = {
2: ['Article — hx-trigger et le debounce', 'Article — La localité du comportement'],
3: ['Article — Hyperview et le HXML', 'Article — Sécuriser une CSP avec htmx']
};
var items = (extra[page] || []).map(function(t){ return '<li>' + t + '</li>'; }).join('');
if (page >= 3) {
return { status: 200, body: items + '<li style="color:var(--ink-soft);font-style:italic;">Fin de la liste.</li>' };
}
return { status: 200, body: items +
'<button class="btn small" hx-get="/demo/more?page=' + (page + 1) + '" hx-target="this" hx-swap="outerHTML">Charger plus ↓</button>' };
},
'POST /demo/start-job': function(){
jobProgress = 0;
return { status: 200, body: jobFragment() };
},
'GET /demo/job-status': function(){
jobProgress = Math.min(100, jobProgress + 22);
return { status: 200, body: jobFragment() };
}
};
function jobFragment(){
if (jobProgress >= 100) {
return '<div><div class="feedback ok">✓ Traitement terminé.</div></div>';
}
return '<div hx-get="/demo/job-status" hx-trigger="load, every 800ms" hx-target="this" hx-swap="outerHTML">' +
'<div class="progress-track"><div class="progress-fill" style="width:' + jobProgress + '%;"></div></div>' +
'<p style="font-size:.82rem;color:var(--ink-soft);margin:.4rem 0 0;">' + jobProgress + '\u00a0%</p></div>';
}
// routes dynamiques /demo/task/:id (DELETE)
var TASKS = {
1: 'Rédiger la documentation htmx',
2: 'Migrer le back-office vers hx-boost',
3: 'Ajouter un indicateur de chargement'
};
function handleDynamic(method, path, body){
var m = /^\/demo\/task\/(\d+)$/.exec(path);
if (m && method === 'DELETE') {
var id = m[1];
var titre = TASKS[id] || 'Tâche';
return { status: 200, body:
'<div id="toast-region" hx-swap-oob="true"><div class="toast">Tâche «\u00a0' + esc(titre) + '\u00a0» supprimée ✓</div></div>' };
}
return null;
}
/* ---- Faux XMLHttpRequest, surface minimale utilisée par htmx ---- */
function MockXHR(){
this.readyState = 0;
this.status = 0;
this.responseText = '';
this.response = '';
this.withCredentials = false;
this._headers = {};
this._reqHeaders = {};
this._listeners = {};
}
MockXHR.UNSENT = 0; MockXHR.OPENED = 1; MockXHR.HEADERS_RECEIVED = 2; MockXHR.LOADING = 3; MockXHR.DONE = 4;
MockXHR.prototype.UNSENT = 0; MockXHR.prototype.OPENED = 1; MockXHR.prototype.HEADERS_RECEIVED = 2;
MockXHR.prototype.LOADING = 3; MockXHR.prototype.DONE = 4;
MockXHR.prototype.open = function(method, url){ this._method = method.toUpperCase(); this._url = url; this.readyState = 1; this._fire('readystatechange'); };
MockXHR.prototype.setRequestHeader = function(k, v){ this._reqHeaders[k] = v; };
MockXHR.prototype.getResponseHeader = function(k){ return this._headers[k.toLowerCase()] || null; };
MockXHR.prototype.getAllResponseHeaders = function(){
var h = this._headers, out = [];
for (var k in h) out.push(k + ': ' + h[k]);
return out.join('\r\n');
};
MockXHR.prototype.overrideMimeType = function(){};
MockXHR.prototype.addEventListener = function(type, fn){
(this._listeners[type] = this._listeners[type] || []).push(fn);
};
MockXHR.prototype.removeEventListener = function(type, fn){
var l = this._listeners[type]; if (!l) return;
var i = l.indexOf(fn); if (i > -1) l.splice(i, 1);
};
MockXHR.prototype._fire = function(type){
var ev; try { ev = new Event(type); } catch (e) { ev = document.createEvent('Event'); ev.initEvent(type, false, false); }
(this._listeners[type] || []).slice().forEach(function(fn){ fn.call(this, ev); }, this);
var onName = 'on' + type;
if (typeof this[onName] === 'function') this[onName](ev);
};
MockXHR.prototype.abort = function(){ this.readyState = 4; this.status = 0; this._fire('abort'); this._fire('loadend'); };
MockXHR.prototype.send = function(body){
var self = this;
var path = (this._url || '').split('?')[0];
var key = this._method + ' ' + path;
self._fire('loadstart');
setTimeout(function(){
self.readyState = 2; self._fire('readystatechange');
setTimeout(function(){
self.readyState = 3; self._fire('readystatechange'); self._fire('progress');
setTimeout(function(){
var handler = ROUTES[key];
var result = handler ? handler(self._url, body) : handleDynamic(self._method, path, body);
if (!result) result = { status: 404, body: '<p class="feedback err">Route de démonstration introuvable : ' + esc(key) + '</p>' };
self.status = result.status;
self.statusText = result.status === 200 ? 'OK' : 'Not Found';
self.responseText = result.body;
self.response = result.body;
self._headers = { 'content-type': 'text/html; charset=utf-8' };
self.readyState = 4;
self._fire('readystatechange');
self._fire('load');
self._fire('loadend');
}, 260 + Math.random() * 220);
}, 40);
}, 20);
};
window.XMLHttpRequest = MockXHR;
})();
</script>
</body>
</html>