WTFetch, pour « What The Fetch », une alternative légère à htmx.
htmx, c'est séduisant, efficace et très pratique. Charger du contenu en asynchrone sans recharger la page, piloté depuis le HTML, sans écrire de JavaScript, c'est hyper gentil pour les utilisateurs et l'éco-conception.
En pratique, pour des besoins simples, c'est 14 ko de librairie pour, souvent, trois attributs utilisés.
WTFetch couvre les patterns les plus simples avec moins de 150 lignes (sans les commentaires) de vanilla JS et aucune dépendance.
- Chargement au load, au scroll (IntersectionObserver) ou au clic ;
- Gestion des erreurs ;
- Gestion des types de réponse (html par défaut, text, json et xml) ;
- Gestion du focus, du statut busy, a.k.a. accessibilité.
En bonus, ça gère aussi les scripts injectés et du nonce pour des CSP mignonnes. C'est la même philosophie que htmx : piloter le comportement depuis le HTML, mais sans embarquer ce dont vous n'avez pas besoin.
Et pour ceux qui utilisent déjà htmx avec les patterns simples — load, intersect, click — WTFetch reconnaît les attributs data-hx-* quand htmx est absent. La migration dans les deux sens se fait sans toucher au HTML.
En pratique, ça donne ça. Et c'est cadeau.
Téléchargement
Pour une fois, on va commencer par le fichier.
Taille brute : 13,78 kilo-octets (~4,82 kilo-octets gzip)
/**
* Source Kortic https://www.kortic.com/le-blog-kortic/2026/05/11/wtfetch-fetch-pilote-depuis-le-dom-en-200-lignes-accessibilite-native-incluse.html
* licence Creative Commons CC BY-NC-SA 4.0
* https://creativecommons.org/licenses/by-nc-sa/4.0/deed.fr
* Attribution, partage dans les mêmes conditions, pas d'utilisation commerciale
*/
/**
* wtfetch.js - What The Fetch
* Micro-librairie vanilla JS, version simplifiée de htmx pour les patterns courants : chargement au
* montage du DOM, chargement au scroll (lazy), chargement au clic.
*/
const WTFetch = (() => {
// performance?.mark('wtf:start');
// Si htmx est chargé, il gère ses propres attributs data-hx-*
// Sinon, WTFetch prend le relais sur data-hx-* en fallback
const HAS_HTM_X = typeof window.htmx !== 'undefined';
// Pour debug en dev : à vous d'initialiser window.localhost quelque part :-)
// (window.localhost && HAS_HTM_X) && console.info('[WTFetch] htmx détecté — data-hx-* gérés par htmx, data-wtf-* gérés par WTFetch');
// Configuration
const CONFIG = JSON.parse(
document.querySelector('meta[name="wtfetch-config"]')?.content ?? '{}'
);
// Nonce CSP - requis si la page utilise script-src avec nonce
const NONCE = CONFIG.nonce ?? '';
/**
* Log conditionnel - actif uniquement si CONFIG.debug est vrai
* @param {...*} args - Arguments transmis à console.log
*/
const log = (...args) => CONFIG.debug && console.log('[WTFetch]', ...args);
/**
* Ouvre un groupe console collapsé - actif uniquement si CONFIG.debug est vrai
* @param {string} label - Libellé du groupe
*/
const logGroup = label => CONFIG.debug && console.groupCollapsed('[WTFetch]', label);
/**
* Ferme le groupe console courant - actif uniquement si CONFIG.debug est vrai
*/
const logGroupEnd = () => CONFIG.debug && console.groupEnd();
// Sélecteurs focusables - surchargeables globalement via window.focusableSelectors
const FOCUSABLE = window.focusableSelectors
?? `a[href], area[href], summary, select:not([disabled]), textarea:not([disabled]),`
+ `button:not([disabled]), input:not([type="hidden"]):not([disabled]), [role="button"], [tabindex]:not([tabindex="-1"]),`
+ `[contenteditable], object, iframe, embed, audio[controls], video[controls], details:not([disabled]),`
+ `label[for], fieldset:not([disabled]), progress, [role="menuitem"], [role="checkbox"], [role="option"], dialog`;
// i18n - message d'annonce par défaut, surchargeable via window.i18n.wtfAnnounce
const i18n = window.i18n || {};
const ANNOUNCE_DEFAULT = i18n.wtfAnnounce || 'Contenu mis à jour';
/**
* Réexécute les balises <script> injectées via innerHTML.
* innerHTML ne les exécute pas nativement : on les recrée avec le nonce CSP de la page.
* Les scripts avec src externes sont chargés de façon asynchrone par le navigateur.
* @param {HTMLElement} target - Élément contenant les scripts à exécuter
*/
const execScripts = target => {
target.querySelectorAll('script').forEach(orig => {
const script = document.createElement('script');
[...orig.attributes].forEach(attr => script.setAttribute(attr.name, attr.value));
if (NONCE) script.nonce = NONCE;
if (!orig.src) script.textContent = orig.textContent;
log('script exécuté (nonce:', NONCE ? NONCE.slice(0, 8) + '...' : 'absent', ') :', orig.src || orig.textContent.slice(0, 80));
orig.replaceWith(script);
});
};
/**
* Déplace le focus clavier vers le premier élément focusable dans la cible.
* Si aucun élément focusable n'est trouvé, focus sur la cible elle-même
* avec un tabindex="-1" synthétique retiré au blur.
* @param {HTMLElement} target - Élément dans lequel chercher le focus
*/
const moveFocus = target => {
const focusable = target.querySelector(FOCUSABLE);
if (focusable) {
focusable.focus();
return;
}
// Fallback : focus sur la cible elle-même
// tabindex="-1" posé synthétiquement s'il est absent, retiré au blur
const synthetic = !target.hasAttribute('tabindex');
if (synthetic) target.setAttribute('tabindex', '-1');
target.focus();
if (synthetic) target.addEventListener('blur', () => target.removeAttribute('tabindex'), {once: true});
};
/**
* Annonce un message sr-only post-injection via l'élément [data-wtf-announcer].
* Priorité : data-wtf-announce > window.i18n.wtfAnnounce > fallback "Contenu mis à jour".
* Le message est effacé après 500ms (délai nécessaire pour NVDA/VoiceOver).
* Dégradation silencieuse si [data-wtf-announcer] est absent du DOM.
* @param {HTMLElement|null} source - Élément déclencheur portant éventuellement data-wtf-announce
*/
const announce = source => {
const announcer = document.querySelector('[data-wtf-announcer]');
if (!announcer) return;
const message = source?.dataset.wtfAnnounce || ANNOUNCE_DEFAULT;
announcer.textContent = message;
log('announce :', message);
setTimeout(() => {
announcer.textContent = '';
}, 500);
};
/**
* Résout l'élément cible d'injection à partir du déclencheur.
* Retourne l'élément pointé par data-wtf-target, ou le déclencheur lui-même si absent.
* @param {HTMLElement} el - Élément déclencheur
* @returns {HTMLElement|null} Élément cible, ou null si le sélecteur ne trouve rien
*/
const resolveTarget = el => {
const targetSelector = el.dataset.wtfTarget ?? el.dataset.hxTarget;
if (!targetSelector) return el;
const target = document.querySelector(targetSelector);
if (!target) log('target introuvable :', targetSelector);
return target;
};
/**
* Parse la réponse fetch selon data-wtf-type et livre le contenu à la cible.
* - html (défaut) : injection innerHTML + exécution des scripts + annonce sr-only
* - json : JSON.parse, dispatch wtfetch:success avec e.detail.data
* - text : chaîne brute, dispatch wtfetch:success avec e.detail.data
* - xml : DOMParser, dispatch wtfetch:success avec e.detail.data (XMLDocument)
* @param {Response} res - Réponse fetch
* @param {string} type - Type de réponse attendu (html|json|text|xml)
* @param {HTMLElement} target - Élément cible de l'injection
* @param {HTMLElement|null} source - Élément déclencheur (pour focus et announce)
* @param {string} url - URL appelée (transmise dans les événements)
* @returns {Promise<void>}
*/
const parseAndDeliver = async (res, type, target, source, url) => {
switch (type) {
case 'json': {
const data = await res.json();
log('json reçu :', data);
target.dispatchEvent(new CustomEvent('wtfetch:success', {detail: {url, data}, bubbles: true}));
break;
}
case 'text': {
const data = await res.text();
log('text reçu :', data);
target.dispatchEvent(new CustomEvent('wtfetch:success', {detail: {url, data}, bubbles: true}));
break;
}
case 'xml': {
const data = new DOMParser().parseFromString(await res.text(), 'application/xml');
log('xml reçu :', data);
target.dispatchEvent(new CustomEvent('wtfetch:success', {detail: {url, data}, bubbles: true}));
break;
}
default: {
const html = await res.text();
log('html reçu :', html.length, 'chars : ', html.slice(0, 120));
target.innerHTML = html;
log('target après injection :', target.innerHTML.slice(0, 120));
execScripts(target);
('wtfFocus' in (source ?? target).dataset) && moveFocus(target);
announce(source ?? target);
// Callback post-injection
window[CONFIG.callback]?.(target);
}
}
};
// Suivi des fetches en cours pour éviter les doubles déclenchements
// (ex : intersect qui fire deux fois sur certains navigateurs)
const pending = new WeakSet();
/**
* Déclenche un fetch vers url et injecte le résultat dans target.
* Ignoré si un fetch est déjà en cours sur la même cible (WeakSet pending).
* Pose aria-busy="true" sur la cible pendant le fetch, le retire dans finally.
* Dispatche wtfetch:error sur la cible en cas d'erreur HTTP ou réseau.
* @param {string} url - URL à appeler en GET
* @param {HTMLElement|null} target - Élément cible de l'injection
* @param {HTMLElement|null} [source] - Élément déclencheur (optionnel, peut être identique à target)
* @returns {Promise<void>}
*/
const inject = async (url, target, source) => {
if (!target) return;
if (pending.has(target)) {
log('inject ignoré - fetch déjà en cours sur', target);
return;
}
pending.add(target);
logGroup(`inject → ${url}`);
log('target:', target, '| source:', source);
target.setAttribute('aria-busy', 'true');
try {
const res = await fetch(url, {headers: {'X-Requested-With': 'wtfetch'}});
log('response', res.status, res.ok);
// Erreur HTTP : on dispatche et on sort, ce n'est pas une exception réseau
if (!res.ok) {
target.dispatchEvent(new CustomEvent('wtfetch:error', {detail: {url, status: res.status}, bubbles: true}));
return;
}
await parseAndDeliver(res, (source ?? target).dataset.wtfType, target, source, url);
} catch (err) {
// Erreurs réseau uniquement (fetch rejeté, timeout, CORS...)
log('erreur réseau :', err);
target.dispatchEvent(new CustomEvent('wtfetch:error', {detail: {url, err}, bubbles: true}));
} finally {
pending.delete(target);
target.removeAttribute('aria-busy');
logGroupEnd();
}
};
// Triggers
/**
* Supprime tous les attributs data-wtf-* de l'élément et pose une classe wtf-init-{trigger}
* pour tracer le type de trigger ayant déclenché le chargement.
* Appelé après inject sur les triggers load et intersect (pas click).
* @param {HTMLElement} el - Élément à nettoyer
*/
const cleanup = el => {
el.classList.add(`wtf-init-${el.dataset.wtfTrigger || el.dataset.hxTrigger || 'load'}`);
Object.keys(el.dataset)
.filter(k => k.startsWith('wtf') || (!HAS_HTM_X && k.startsWith('hx')))
.forEach(k => el.removeAttribute(`data-${k.replace(/([A-Z])/g, '-$1').toLowerCase()}`));
};
/**
* Déclenche inject immédiatement au montage du DOM puis nettoie l'élément.
* @param {HTMLElement} el - Élément déclencheur
* @param url
*/
const bindLoad = (el, url) => {
inject(url, resolveTarget(el), el);
cleanup(el);
};
/**
* Déclenche inject via IntersectionObserver quand l'élément entre dans le viewport.
* Le fetch n'est déclenché qu'une seule fois (unobserve + cleanup après intersection).
* @param {HTMLElement} el - Élément déclencheur à observer
* Syntaxe compatible htmx :
* data-wtf-trigger="intersect threshold:0.5"
* data-wtf-root-margin="300px"
* En mode htmx, ces attributs sont lus nativement par htmx sans adaptation.
*/
const bindIntersect = (el) => {
const triggerRaw = el.dataset.wtfTrigger ?? el.dataset.hxTrigger ?? '';
const threshold = parseFloat(
triggerRaw.match(/threshold:([\d.]+)/)?.[1] ?? '0'
);
const rootMargin = el.dataset.wtfRootMargin ?? el.dataset.hxRootMargin ?? '0px';
log('intersect - threshold:', threshold, '| rootMargin:', rootMargin);
new IntersectionObserver((entries, obs) => {
entries.filter(e => e.isIntersecting).forEach(e => {
inject(
e.target.dataset.wtfGet ?? e.target.dataset.hxGet,
resolveTarget(e.target), e.target
);
obs.unobserve(e.target);
cleanup(e.target);
});
}, {threshold, rootMargin}).observe(el);
};
/**
* Déclenche inject à chaque clic sur l'élément.
* Re-déclenchable, sauf si un fetch est déjà en cours sur la cible (géré par pending).
* Les attributs data-wtf-* ne sont pas nettoyés (permettre les clics multiples).
* @param {HTMLElement} el - Élément déclencheur
*/
const bindClick = el =>
el.addEventListener('click', () => inject(
el.dataset.wtfGet ?? el.dataset.hxGet,
resolveTarget(el), el
));
/**
* Scanne root à la recherche d'éléments [data-wtf-get] et initialise leur trigger.
* À appeler manuellement après injection dynamique de HTML contenant des éléments data-wtf-*.
* @param {Document|HTMLElement} [root=document] - Racine du scan
*/
const init = (root = document) => {
const selector = HAS_HTM_X
? '[data-wtf-get]'
: '[data-wtf-get], [data-hx-get]';
const els = root.querySelectorAll(selector);
logGroup(`init — ${els.length} élément(s)`);
log([...els]);
els.forEach(el => {
const url = el.dataset.wtfGet ?? el.dataset.hxGet;
const triggerRaw = el.dataset.wtfTrigger ?? el.dataset.hxTrigger ?? 'load';
const [trigger] = triggerRaw.split(' ');
switch (trigger) {
case 'load':
bindLoad(el, url);
break;
case 'intersect':
bindIntersect(el);
break;
case 'click':
bindClick(el);
break;
default:
log('trigger inconnu :', trigger, el);
}
});
logGroupEnd();
};
// Init auto au DOMContentLoaded
document.readyState === 'loading'
? document.addEventListener('DOMContentLoaded', () => init())
: init();
// performance?.mark('wtf:end');
// console.info(`Init WTFetch ${performance?.measure('Initialisation WTFetch', 'wtf:start', 'wtf:end').duration}ms`);
// API publique pour usage programmatique (ex: après rendu dynamique)
return {init, inject};
})();
/**
* Rappel générique de fonctions utilitaires dans les fetch.
* Par exemple ici, gestion des liens blank, des tooltips, etc…
* À configurer dans la balise -->
* <meta name="wtfetch-config" content='{"callback": "wtfCallBack"}'>
* @param root
*/
function wtfCallBack(root) {
document.attachPopovers(root);
toggleBlank(root);
}
Un petit read me
À récupérer, mais on détaille juste après…
/**
* -----------------------------------------------------------------------------
* INSTALLATION
* -----------------------------------------------------------------------------
* 1. Ajouter la meta de configuration dans le <head> de la page si il y une gestion de nonce CSP :
*
* <meta name="wtfetch-config" content='{"nonce": "<?= NONCE ?>"}'>
* <meta name="wtfetch-config" content='{"debug": true}'> pour affichage de debug
*
* 2. Inclure le script en fin de <body> ou en <head> avec defer :
*
* <script defer src="/js/wtfetch.js"></script>
* ou en inline :
* <script nonce="<?= NONCE ?>"><?php include 'wtfetch.js' ?></script>
*
* -----------------------------------------------------------------------------
* OPTIONS DE CONFIGURATION (meta wtfetch-config, format JSON)
* -----------------------------------------------------------------------------
* nonce {string}
* Nonce CSP de la page. Obligatoire si la politique de sécurité du site
* utilise script-src avec nonce. Doit être régénéré par le serveur à
* chaque requête.
*
* debug {boolean}
* Active les logs de débogage dans la console du navigateur.
* A désactiver en production.
*
* Exemple :
* <meta name="wtfetch-config" content='{"nonce":"abc123","debug":true}'>
*
* -----------------------------------------------------------------------------
* API HTML - ATTRIBUTS data-wtf-*
* -----------------------------------------------------------------------------
* Tous les attributs se posent directement sur l'élément HTML déclencheur,
* sauf aria-live qui se pose sur l'élément CIBLE (voir section Accessibilité).
*
* data-wtf-get="url"
* URL à appeler en GET. Obligatoire.
* Exemple : data-wtf-get="/xhr/mon-fragment"
*
* data-wtf-trigger="load | intersect | click"
* Moment où le fetch est déclenché. Optionnel, "load" par défaut.
*
* load : dès que le DOM est prêt (ou immédiatement si le script est chargé
* après le DOMContentLoaded). Utile pour du contenu différé au montage.
*
* intersect : quand l'élément entre dans le viewport (lazy loading).
* Marge d'anticipation par défaut : 100px en haut et bas.
* Le fetch n'est déclenché qu'une seule fois.
*
* click : au clic sur l'élément. Peut être re-déclenché à chaque clic
* (sauf si un fetch est déjà en cours sur la cible).
*
* data-wtf-target="#selecteur-css"
* Sélecteur CSS de l'élément qui recevra le contenu injecté.
* Si absent, le contenu est injecté dans l'élément déclencheur lui-même.
* Exemple : data-wtf-target="#zone-de-contenu"
*
* data-wtf-type="html | json | text | xml"
* Type de réponse attendue du serveur. Détermine comment la réponse
* est parsée et ce qui se passe ensuite.
*
* html (défaut) : la réponse est injectée en innerHTML dans la cible.
* Les éventuels <script> dans la réponse sont exécutés.
*
* json : la réponse est parsée en objet JavaScript. Rien n'est injecté
* dans le DOM. Un événement wtfetch:success est dispatché sur la cible
* avec e.detail.data contenant l'objet parsé.
*
* text : la réponse est récupérée comme chaîne brute. Même comportement
* que json : événement wtfetch:success avec e.detail.data contenant
* la chaîne.
*
* xml : la réponse est parsée en XMLDocument via DOMParser. Même
* comportement que json : événement wtfetch:success avec e.detail.data
* contenant le document XML.
*
* data-wtf-focus
* Attribut booléen (sans valeur). Si présent, le focus clavier est
* déplacé après injection vers le premier élément focusable trouvé dans
* la cible. Si aucun élément focusable n'est trouvé, la cible elle-même
* reçoit le focus (tabindex="-1" posé temporairement).
* Utile pour l'accessibilité lors de mises à jour de contenu importantes.
* Exemple : <div data-wtf-get="/xhr/resultat" data-wtf-focus>
*
* data-wtf-announce="message"
* Message lu par les AT après injection réussie (type html uniquement).
* Optionnel. Si absent, utilise window.i18n.wtfAnnounce s'il est défini,
* sinon le fallback interne "Contenu mis à jour".
* Nécessite qu'un élément data-wtf-announcer soit présent dans le DOM
* (voir section Accessibilité).
* Exemple : data-wtf-announce="Résultats chargés"
*
* -----------------------------------------------------------------------------
* ACCESSIBILITE - LIVE REGIONS
* -----------------------------------------------------------------------------
* Pour que les technologies d'assistance annoncent le contenu injecté,
* l'élément cible doit porter aria-live AVANT toute mutation du DOM.
* Safari/VoiceOver ignore aria-live posé dynamiquement après coup.
* C'est donc à poser en HTML statique, pas via JavaScript.
*
* aria-live="polite"
* Annonce le nouveau contenu à la fin de l'activité utilisateur en cours.
* Pour les mises à jour non critiques (résultats, contenu progressif...).
*
* aria-live="assertive"
* Interrompt immédiatement la restitution vocale. Réserver aux feedbacks
* urgents et messages d'erreur.
*
* aria-atomic="true"
* Optionnel. Les AT lisent la cible entière comme une unité plutôt que
* noeud par noeud. Utile quand la cible contient plusieurs noeuds formant
* un tout cohérent (ex: résultat de recherche, statut composite).
* Par défaut à false, ne pas poser si non nécessaire.
*
* Exemples :
* <!-- feedback utilisateur, lecture en bloc -->
* <div id="feedback" aria-live="assertive" aria-atomic="true"></div>
*
* <!-- enrichissement progressif, lecture fragmentée -->
* <div id="recommendations" aria-live="polite"></div>
*
* <!-- déclencheur pointant vers la cible live -->
* <button data-wtf-get="/xhr/search"
* data-wtf-trigger="click"
* data-wtf-target="#feedback">Rechercher</button>
*
* wtfetch pose aria-busy="true" sur la cible pendant le fetch et le retire
* à la fin, ce qui permet aux AT de savoir qu'une mise à jour est en cours.
*
* -----------------------------------------------------------------------------
* ACCESSIBILITE - ANNOUNCER
* -----------------------------------------------------------------------------
* Pour annoncer un message sr-only après injection (ex: "Contenu mis à jour")
* sans dépendre du contenu injecté lui-même, wtfetch utilise un élément
* announcer dédié, à poser en HTML statique dans la page.
*
* L'élément doit être présent dans le DOM avant tout fetch - Safari/VoiceOver
* exige que aria-live soit enregistré avant toute mutation.
* wtfetch le cible via l'attribut data-wtf-announcer.
*
* Markup recommandé (à placer en fin de <body>) :
* <div data-wtf-announcer
* aria-live="polite"
* aria-atomic="true"
* class="sr-only"></div> // ou visually-hidden, etc. selon votre framework
*
* Le message annoncé est résolu dans cet ordre de priorité :
* 1. data-wtf-announce sur le déclencheur
* 2. window.i18n.wtfAnnounce
* 3. fallback interne "Contenu mis à jour"
*
* Pour internationaliser le message par défaut :
* window.i18n = { wtfAnnounce: 'Content updated' };
*
* Si data-wtf-announcer est absent du DOM, aucune annonce n'est émise
* (dégradation silencieuse).
*
* -----------------------------------------------------------------------------
* EVENEMENTS JAVASCRIPT
* -----------------------------------------------------------------------------
* Tous les événements sont dispatchés sur l'élément cible (data-wtf-target
* ou l'élément déclencheur) et remontent dans le DOM (bubbles: true).
*
* wtfetch:success
* Dispatché après un fetch réussi pour les types json, text et xml.
* Non dispatché pour html (l'injection dans le DOM suffit).
* Propriétés de e.detail :
* url {string} URL appelée
* data {*} Donnée parsée (objet, chaîne ou XMLDocument)
*
* wtfetch:error
* Dispatché en cas d'erreur HTTP (404, 500...) ou d'erreur réseau.
* Propriétés de e.detail selon le cas :
* url {string} URL appelée
* status {number} Code HTTP (erreurs HTTP uniquement)
* err {Error} Objet erreur (erreurs réseau uniquement)
*
* Exemple d'écoute :
* document.querySelector('#ma-zone').addEventListener('wtfetch:success', e => {
* console.log('données reçues :', e.detail.data);
* });
* document.addEventListener('wtfetch:error', e => {
* console.error('erreur wtfetch :', e.detail);
* });
*
* -----------------------------------------------------------------------------
* API JAVASCRIPT PUBLIQUE
* -----------------------------------------------------------------------------
* WTFetch est exposé en variable globale avec deux méthodes publiques :
*
* WTFetch.init(root)
* Scanne root (défaut : document) à la recherche d'éléments data-wtf-*
* et les initialise. A appeler manuellement après avoir injecté du HTML
* dynamiquement qui contient lui-même des éléments data-wtf-*.
* Exemple : WTFetch.init(document.querySelector('#nouveau-contenu'));
*
* WTFetch.inject(url, target, source)
* Déclenche un fetch manuellement.
* url {string} URL à appeler
* target {HTMLElement} Elément cible de l'injection
* source {HTMLElement} Elément source (pour lire data-wtf-focus,
* data-wtf-type...). Peut être identique à target.
* Exemple : WTFetch.inject('/xhr/mon-fragment', document.querySelector('#zone'));
*
* -----------------------------------------------------------------------------
* SELECTEURS FOCUSABLES PERSONNALISES
* -----------------------------------------------------------------------------
* La liste des éléments considérés comme focusables (utilisée par data-wtf-focus)
* peut être surchargée globalement via window.focusableSelectors avant le
* chargement du script :
*
* window.focusableSelectors = 'a[href], button:not([disabled])';
*
* -----------------------------------------------------------------------------
* EXEMPLES D'UTILISATION
* -----------------------------------------------------------------------------
*
* Chargement au montage, injection dans l'élément lui-même :
* <div data-wtf-get="/xhr/sidebar"></div>
*
* Chargement au montage, injection dans une cible distincte :
* <section data-wtf-get="/xhr/articles"
* data-wtf-target="#liste-articles"></section>
* <div id="liste-articles"></div>
*
* Lazy loading au scroll, marge par défaut (100px) :
* <div data-wtf-get="/xhr/carousel" data-wtf-trigger="intersect"></div>
*
* Lazy loading au scroll, marge personnalisée (300px) :
* <div data-wtf-get="/xhr/carousel" data-wtf-trigger="intersect" data-wtf-root-margin="300px"></div>
*
* Chargement au clic avec focus post-injection :
* <button data-wtf-get="/xhr/details"
* data-wtf-trigger="click"
* data-wtf-target="#panneau"
* data-wtf-focus>Charger</button>
*
* Récupération JSON avec handler :
* <div id="stats"
* data-wtf-get="/api/stats"
* data-wtf-type="json"></div>
* <script>
* document.querySelector('#stats').addEventListener('wtfetch:success', e => {
* document.querySelector('#stats').textContent = e.detail.data.total;
* });
* </script>
*
* Feedback utilisateur avec live region et annonce sr-only :
* <div data-wtf-announcer
* aria-live="polite"
* aria-atomic="true"
* class="sr-only"></div> // ou visually-hidden, etc. selon votre framework
*
* <div id="results" aria-live="polite" aria-atomic="true"></div>
* <button data-wtf-get="/xhr/search"
* data-wtf-trigger="click"
* data-wtf-target="#results"
* data-wtf-announce="Résultats de recherche chargés">Rechercher</button>
*
* =============================================================================
*/
C'est bon, vous vous en remettez ? On continue alors…
Installation
C'est hyper complexe, attention ! Deux étapes.
1. La meta de configuration dans le <head>
Optionnelle, mais très utile pour gérer du nonce CSP ou le debug.
<meta name="wtfetch-config" content='{"nonce": "VOTRE_NONCE_CSP"}'>
ou
<meta name="wtfetch-config" content='{"debug": true}'>
2. Le script en fin de <body> ou dans le <head> avec defer si appelé par chemin de fichier, ou inline directement.
<head>
[…]
<script defer src="/js/wtfetch.js"></script>
[…]
</head>
<!-- ou -->
<body>
[…]
<!-- avec un nonce -->
<script nonce="<?= NONCE ?>"><?php include '/path/js/wtfetch.js' ?></script>
</body>
Et c'est tout. Vous avez survécu ? Aucune initialisation manuelle : WTFetch se lance seul.
Configuration
La meta wtfetch-config accepte un objet JSON avec deux options :
| Option | Type | Description |
|---|---|---|
nonce |
string |
Nonce CSP de la page. Requis uniquement si la politique de sécurité du site utilise script-src avec nonce. À régénérer côté serveur à chaque requête. |
debug |
boolean |
Active les logs dans la console navigateur. À désactiver en production. |
<meta name="wtfetch-config" content='{"nonce":"abc123","debug":true}'>
Coexistence avec htmx
Si htmx est chargé sur la page, WTFetch le détecte et se limite à ses propres attributs data-wtf-*. htmx garde la main sur data-hx-*.
Si htmx est absent, WTFetch reconnaît également data-hx-get, data-hx-target et data-hx-trigger pour les patterns simples. Une migration dans les deux sens sans toucher au HTML.
<!-- Fonctionne avec htmx ou WTFetch (sans htmx) -->
<div data-hx-get="/xhr/sidebar" data-hx-trigger="load"></div>
<!-- Spécifique WTFetch -->
<div data-wtf-get="/xhr/sidebar"></div>
API HTML
Tout se passe dans le HTML. Aucun JavaScript à écrire pour les cas courants.
Bêtise incluse avec les attributs data-wtf-*. Juste pour le plaisir d'avoir ça dans le code…
data-wtf-get="url"
L'URL à appeler en GET. Obligatoire, tout le reste est optionnel.
<div data-wtf-get="/xhr/mon-fragment"></div>
data-wtf-trigger
Le moment où le fetch se déclenche. Trois valeurs possibles :
| Valeur | Comportement |
|---|---|
load |
Dès que le DOM est prêt. Valeur par défaut si l'attribut est absent. |
intersect |
Quand l'élément entre dans le viewport (lazy loading). Déclenché une seule fois. Configurable via data-wtf-root-margin et threshold. |
click |
Au clic sur l'élément. Peut se re-déclencher à chaque clic. |
<!-- Chargement au montage (défaut) -->
<div data-wtf-get="/xhr/sidebar"></div>
<!-- Lazy loading au scroll, marge 300px -->
<div data-wtf-get="/xhr/carousel"
data-wtf-trigger="intersect"
data-wtf-root-margin="300px"></div>
<!-- Lazy loading avec seuil de visibilité à 50% -->
<div data-wtf-get="/xhr/carousel"
data-wtf-trigger="intersect threshold:0.5"></div>
<!-- Au clic -->
<button data-wtf-get="/xhr/details" data-wtf-trigger="click">Charger</button>
data-wtf-root-margin
Marge d'anticipation pour le trigger intersect, en pixels. Équivalent du rootMargin de l'IntersectionObserver.
<div data-wtf-get="/xhr/carousel"
data-wtf-trigger="intersect"
data-wtf-root-margin="500px"></div>
data-wtf-target="#selecteur-css"
L'élément qui recevra le contenu injecté. Si absent, c'est l'élément déclencheur lui-même.
<section data-wtf-get="/xhr/articles" data-wtf-target="#liste-articles"></section>
<div id="liste-articles"></div>
data-wtf-type
Le type de réponse attendu. Détermine comment la réponse est parsée.
| Valeur | Comportement |
|---|---|
html |
Défaut. Injecté en innerHTML dans la cible. Les <script> sont exécutés avec le nonce CSP. |
json |
Parsé en objet JavaScript. Événement wtfetch:success dispatché sur la cible avec e.detail.data. |
text |
Chaîne brute. Même comportement que json. |
xml |
Parsé en XMLDocument via DOMParser. Même comportement que json. |
data-wtf-focus
Attribut booléen (sans valeur). Après injection, le focus est déplacé vers le premier élément focusable dans la cible. Si aucun n'est trouvé, la cible elle-même reçoit le focus.
<button data-wtf-get="/xhr/details"
data-wtf-trigger="click"
data-wtf-target="#panneau"
data-wtf-focus>Charger les détails</button>
Accessibilité
Le petit truc en plus, sans chercher querelle ou comparaison stérile.
htmx délègue la gestion de l'accessibilité au développeur : aucun aria-busy natif, aucune gestion du focus après injection, aucun filet. Ce sont des choses que vous pouvez faire vous-même, mais que vous pouvez aussi oublier. Et, d'expérience, c'est quasiment toujours oublié.
WTFetch les intègre par défaut.
aria-busy
aria-busy="true" est posé automatiquement sur la cible pendant le chargement, et retiré dès que le fetch est terminé, succès ou erreur. Les technologies d'assistance sont ainsi informées qu'une mise à jour est en cours, sans que vous ayez à y penser.
Ça n'exonère pas de gérer les aria-live, aria-atomic et autres compères…
Focus post-injection
Un contenu qui apparaît dans le DOM sans déplacement du focus dans certains cas, c'est une mise à jour invisible pour un utilisateur au clavier ou au lecteur d'écran. data-wtf-focus règle ça en un attribut : le focus est déplacé vers le premier élément focusable de la cible après injection. S'il n'y en a pas, la cible elle-même reçoit le focus temporairement.
La liste des sélecteurs considérés comme focusables est à peu près complète par défaut (liens, boutons, inputs, rôles ARIA…) et surchargeable globalement si besoin, et c'est déjà dans le script.
Live regions
Pour que les technologies d'assistance annoncent le contenu injecté, l'élément cible doit porter aria-live avant toute mutation du DOM. Safari/VoiceOver ignore aria-live posé dynamiquement après coup : c'est à poser en HTML statique, pas via JavaScript.
<!-- feedback utilisateur, lecture en bloc -->
<div id="feedback" aria-live="assertive" aria-atomic="true"></div>
<!-- enrichissement progressif, lecture fragmentée -->
<div id="recommendations" aria-live="polite"></div>
Announcer sr-only
Pour annoncer un message discret après injection sans dépendre du contenu injecté lui-même, WTFetch utilise un élément announcer dédié à poser en HTML statique dans la page. Il doit être présent dans le DOM avant tout fetch, pour la même raison que les live regions.
<div data-wtf-announcer
aria-live="polite"
aria-atomic="true"
class="sr-only"></div>
Le message annoncé est résolu dans cet ordre de priorité :
data-wtf-announcesur le déclencheurwindow.i18n.wtfAnnounce- fallback interne « Contenu mis à jour »
Pour internationaliser le message par défaut :
window.i18n = { wtfAnnounce: 'Content updated' };
Si data-wtf-announcer est absent du DOM, aucune annonce n'est émise.
Événements JavaScript
Tous les événements remontent dans le DOM et sont dispatchés sur l'élément cible.
wtfetch:success
Dispatché après un fetch réussi pour les types json, text et xml.
document.querySelector('#ma-zone').addEventListener('wtfetch:success', e => {
console.log('URL appelée :', e.detail.url);
console.log('Données reçues :', e.detail.data);
});
wtfetch:error
Dispatché en cas d'erreur HTTP (404, 500…) ou d'erreur réseau.
document.addEventListener('wtfetch:error', e => {
// Erreur HTTP
if (e.detail.status) console.error('HTTP', e.detail.status, e.detail.url);
// Erreur réseau (CORS, timeout…)
if (e.detail.err) console.error('Réseau', e.detail.err);
});
API JavaScript publique
WTFetch expose deux méthodes en variable globale.
WTFetch.init(root)
Scanne root (défaut : document) et initialise tous les éléments data-wtf-* trouvés.
À appeler après avoir injecté du HTML dynamique qui contient lui-même des éléments WTFetch.
// Après rendu dynamique d'un composant
WTFetch.init(document.querySelector('#nouveau-contenu'));
WTFetch.inject(url, target, source)
Déclenche un fetch manuellement, sans passer par les attributs HTML.
WTFetch.inject(
'/xhr/mon-fragment',
document.querySelector('#zone'),
document.querySelector('#declencheur') // optionnel
);
Exemples complets
Chargement au montage, injection dans l'élément lui-même
Sur la page d'accueil de Kortic, la liste des articles est alimentée au load de la page, ce qui permet d'avoir des données fraiches dans une page gérée par le cache.
<div data-wtf-get="/xhr/sidebar"></div>
Chargement au montage, injection dans une cible distincte
<section data-wtf-get="/xhr/articles"
data-wtf-target="#liste-articles"></section>
<div id="liste-articles"></div>
Lazy loading au scroll
En pratique, sur la plupart des pages de Kortic, une liste aléatoire de photos est chargée de cette manière. Le script de gestion du défilement (chercher Simca dans le moteur de recherche) est exécuté correctement.
<!-- Marge d'anticipation 500px -->
<div data-wtf-get="/xhr/carousel"
data-wtf-trigger="intersect"
data-wtf-root-margin="500px"></div>
<!-- Seuil de visibilité à 50% -->
<div data-wtf-get="/xhr/carousel"
data-wtf-trigger="intersect threshold:0.5"></div>
Chargement au clic avec focus accessible
<button data-wtf-get="/xhr/details"
data-wtf-trigger="click"
data-wtf-target="#panneau"
data-wtf-focus>Charger</button>
<div id="panneau"></div>
Récupération JSON avec handler
<div id="stats"
data-wtf-get="/api/stats"
data-wtf-type="json"></div>
<script>
document.querySelector('#stats').addEventListener('wtfetch:success', e => {
document.querySelector('#stats').textContent = e.detail.data.total;
});
</script>
Migration depuis htmx
Si votre HTML utilise déjà data-hx-* pour les patterns simples et que vous souhaitez retirer htmx, il suffit de charger WTFetch à la place. Aucune modification du HTML nécessaire.
<!-- Ce markup fonctionne avec htmx ET avec WTFetch seul -->
<div data-hx-get="/xhr/sidebar" data-hx-trigger="load"></div>
<button data-hx-get="/xhr/details"
data-hx-trigger="click"
data-hx-target="#panneau">Charger</button>
Sécurité : Nonce CSP
Les <script> contenus dans les fragments HTML injectés ne s'exécutent pas nativement via innerHTML. WTFetch les recrée en leur appliquant éventuellement le nonce CSP de la page, ce qui permet leur exécution sans avoir à assouplir la politique de sécurité avec 'unsafe-inline'.
Cette mécanique n'est utile que si votre site utilise une CSP avec script-src et nonce. Si ce n'est pas le cas, l'option peut être ignorée : WTFetch fonctionne parfaitement sans.
Ce que ça change ?
htmx est une excellente librairie, pour des projets qui en utilisent réellement la surface. Échanges partiels, hx-boost, hx-push-url, extensions, événements avancés… Si vous n'en avez besoin d'aucun, vous embarquez 14 ko pour rien.
WTFetch cible explicitement les besoins simples : charger un fragment au montage, au scroll, ou au clic, et l'injecter dans le DOM. Rien de plus.
La configuration reste dans le HTML via des attributs data-wtf-*, dans le même esprit que htmx, mais la librairie ne grossit pas avec des fonctionnalités que vous n'utiliserez jamais.
That's all Folks !