Skip to content

Commit 3f4ac1b

Browse files
committed
fix: disclosure.js
1 parent dd3ef2f commit 3f4ac1b

2 files changed

Lines changed: 151 additions & 168 deletions

File tree

assets/default/scripts/development/disclosure.js

Lines changed: 36 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
/**
22
* @module DisclosureSystem
3-
* @version 1.1.0
3+
* @version 1.2.0
44
* @author Olivier C
55
* * --- RAISON D'ÊTRE & VISION ARCHITECTURALE ---
66
* Ce moteur traite les Onglets (.tabs) et les Accordéons (.accordion) comme une seule
@@ -98,7 +98,6 @@ const SUPPORTS_UNTIL_FOUND = "onbeforematch" in window;
9898
const STATE_KEY = "uiState";
9999

100100
// --- DATA LAYOUT (Hot State Cache) ---
101-
// Centralisation de l'état en mémoire vive pour découpler la logique du stockage I/O.
102101
const DisclosureState = {
103102
slug: window.location.pathname,
104103
cache: null,
@@ -107,18 +106,25 @@ const DisclosureState = {
107106
// --- DAL (Data Access Layer) ---
108107
const hydrateState = () => {
109108
if (DisclosureState.cache) return DisclosureState.cache;
109+
110110
try {
111111
const stored = localStorage.getItem(STATE_KEY);
112-
const parsed = stored ? JSON.parse(stored) : { uiState: {} };
113-
if (!parsed.uiState[DisclosureState.slug]) {
112+
const parsed = stored ? JSON.parse(stored) : null;
113+
114+
// Sécurité DOD : garantit un data layout strict même si l'I/O JSON est corrompu
115+
if (!parsed || typeof parsed !== "object")
116+
throw new Error("Invalid payload");
117+
if (!parsed.uiState) parsed.uiState = {};
118+
if (!parsed.uiState[DisclosureState.slug])
114119
parsed.uiState[DisclosureState.slug] = {};
115-
}
120+
116121
DisclosureState.cache = parsed;
117122
} catch {
118-
// localStorage indisponible (navigation privée iOS, quota dépassé, etc.)
119-
// Dégradation silencieuse : état en mémoire volatile, pas de persistance.
123+
// Dégradation silencieuse : layout mémoire pur volatil
120124
DisclosureState.cache = { uiState: { [DisclosureState.slug]: {} } };
121125
}
126+
127+
// INVARIANT CRITIQUE : Toujours retourner le pointeur du cache
122128
return DisclosureState.cache;
123129
};
124130

@@ -127,42 +133,32 @@ const persistState = () => {
127133
try {
128134
localStorage.setItem(STATE_KEY, JSON.stringify(DisclosureState.cache));
129135
} catch {
130-
// Échec silencieux — la session reste fonctionnelle, sans persistance.
136+
// Échec silencieux
131137
}
132138
};
133139

134140
// --- Animation Engine (The Painter) ---
135141
const animatePanel = (panel, isOpening) => {
136-
// Accordéons uniquement — les tab-panels ne transitent pas par ici
137142
if (!panel.classList.contains("accordion-panel")) return;
138143

139144
if (isOpening) {
140-
// Panel déjà révélé par syncState → scrollHeight lisible
141145
panel.style.maxHeight = "0px";
142-
panel.offsetHeight; // Force reflow — commit le point de départ
146+
panel.offsetHeight; // Force reflow
143147
panel.style.maxHeight = `${panel.scrollHeight}px`;
144148

145149
const onEnd = () => {
146-
// removeAttribute libère le panel (flexible au resize / injection de contenu)
147150
panel.removeAttribute("style");
148151
panel.removeEventListener("transitionend", onEnd);
149152
};
150153
panel.addEventListener("transitionend", onEnd);
151154
} else {
152-
// Panel encore visible → scrollHeight > 0, commit le point de départ
153155
panel.style.maxHeight = `${panel.scrollHeight}px`;
154156

155-
// rAF : retire l'inline ET pose aria-hidden atomiquement.
156-
// aria-hidden='true' pilote la transition CSS (max-height: 0 via [aria-hidden='true']).
157-
// hidden='until-found' ne peut pas être le déclencheur : son masquage natif navigateur
158-
// est instantané et couperait la transition avant qu'elle ne s'exécute.
159157
requestAnimationFrame(() => {
160158
panel.removeAttribute("style");
161159
panel.setAttribute("aria-hidden", "true");
162160
});
163161

164-
// Après la transition : upgrade vers until-found si supporté (active CTRL+F).
165-
// C'est ici seulement que le masquage natif navigateur peut être posé sans dommage.
166162
const onEnd = () => {
167163
if (SUPPORTS_UNTIL_FOUND) {
168164
panel.removeAttribute("aria-hidden");
@@ -187,8 +183,6 @@ const transform = (container, cIdx) => {
187183
tabList.className = "tab-list";
188184
}
189185

190-
// Signal source : name sur les summaries → injection de data-singletab sur le container.
191-
// Attribut canonique observable dans le DOM transformé (CSS, devtools, tests).
192186
const hasNamedSummary = Array.from(rawEntities).some((d) =>
193187
d.hasAttribute("name"),
194188
);
@@ -238,7 +232,7 @@ const syncState = (targetTrigger, container, useAnimation = true) => {
238232
const triggers = container.querySelectorAll(
239233
isTabs ? ':scope > .tab-list > [role="tab"]' : ".accordion-summary",
240234
);
241-
const fullState = hydrateState();
235+
const fullState = hydrateState(); // Appel sécurisé
242236

243237
const willBeOpen = isTabs
244238
? true
@@ -257,34 +251,25 @@ const syncState = (targetTrigger, container, useAnimation = true) => {
257251
: currentlyOpen;
258252
const isChanging = shouldOpen !== currentlyOpen;
259253

260-
// FERMETURE animée (accordion) : capturer scrollHeight avant toute mutation.
261-
// animatePanel devient owner du masquage (posé dans le rAF au cycle suivant).
262254
if (isChanging && !shouldOpen && useAnimation) {
263255
animatePanel(panel, false);
264256
}
265257

266-
// Mutation des attributs trigger
267258
trigger.setAttribute("aria-expanded", shouldOpen);
268259
if (isTabs) {
269260
trigger.setAttribute("aria-selected", shouldOpen);
270261
trigger.disabled = shouldOpen;
271262
}
272263

273264
if (shouldOpen) {
274-
// Révélation synchrone — scrollHeight devient lisible pour animatePanel
275265
panel.removeAttribute("hidden");
276266
panel.removeAttribute("aria-hidden");
277267
fullState.uiState[DisclosureState.slug][trigger.id] = "open";
278268

279-
// OUVERTURE animée : panel révélé, scrollHeight > 0
280269
if (isChanging && useAnimation) {
281270
animatePanel(panel, true);
282271
}
283272
} else {
284-
// Masquage immédiat dans deux cas :
285-
// - tabs : animatePanel est inopérant (pas un accordion-panel)
286-
// - useAnimation=false : beforematch, restauration init
287-
// Cas accordion animé : masquage délégué au rAF dans animatePanel(false)
288273
if (!useAnimation || isTabs) {
289274
if (SUPPORTS_UNTIL_FOUND) {
290275
panel.hidden = "until-found";
@@ -300,8 +285,6 @@ const syncState = (targetTrigger, container, useAnimation = true) => {
300285
};
301286

302287
// --- Navigation clavier (tabs uniquement) ---
303-
// Spec ARIA 1.1 : les role="tab" sont navigables par flèches au sein du tablist.
304-
// Tab/Shift+Tab gère le focus entre zones — les flèches gèrent le focus intra-tablist.
305288
const bindTabKeyboard = (container) => {
306289
const tabList = container.querySelector(":scope > .tab-list");
307290
if (!tabList) return;
@@ -349,30 +332,35 @@ export const initDisclosureSystem = () => {
349332

350333
if (isTabs) bindTabKeyboard(container);
351334

352-
// AOT : Évaluation isolée du cache pour CE conteneur uniquement.
353-
// Empêche la mutation en cours de boucle de corrompre l'initialisation des conteneurs suivants.
335+
// Isolement O(N) pour éviter l'écrasement inter-conteneurs
354336
const containerHasOpenTab =
355337
isTabs && Array.from(triggers).some((t) => pageState[t.id] === "open");
356338

339+
// Phase de Boot : Hydratation unidirectionnelle Cache -> DOM sans déclencher syncState
357340
triggers.forEach((trigger) => {
358341
trigger.addEventListener("click", () =>
359342
syncState(trigger, container, true),
360343
);
361344

362-
// Restauration de l'état (sans animation pour le premier rendu)
363-
if (pageState[trigger.id] === "open") {
364-
syncState(trigger, container, false);
365-
} else if (isTabs && !containerHasOpenTab && trigger === triggers[0]) {
366-
syncState(trigger, container, false);
345+
const shouldBeOpen =
346+
pageState[trigger.id] === "open" ||
347+
(isTabs && !containerHasOpenTab && trigger === triggers[0]);
348+
349+
trigger.setAttribute("aria-expanded", shouldBeOpen);
350+
if (isTabs) {
351+
trigger.setAttribute("aria-selected", shouldBeOpen);
352+
trigger.disabled = shouldBeOpen;
353+
}
354+
355+
const pnl = document.getElementById(
356+
trigger.getAttribute("aria-controls"),
357+
);
358+
if (shouldBeOpen) {
359+
pnl.removeAttribute("hidden");
360+
pnl.removeAttribute("aria-hidden");
367361
} else {
368-
const pnl = document.getElementById(
369-
trigger.getAttribute("aria-controls"),
370-
);
371-
if (SUPPORTS_UNTIL_FOUND) {
372-
pnl.hidden = "until-found";
373-
} else {
374-
pnl.setAttribute("aria-hidden", "true");
375-
}
362+
if (SUPPORTS_UNTIL_FOUND) pnl.hidden = "until-found";
363+
else pnl.setAttribute("aria-hidden", "true");
376364
}
377365
});
378366

@@ -382,7 +370,6 @@ export const initDisclosureSystem = () => {
382370
const trigger = document.getElementById(
383371
panel.getAttribute("aria-labelledby"),
384372
);
385-
// Recherche CTRL+F : ouverture instantanée, pas de transition de hauteur
386373
if (trigger) syncState(trigger, container, false);
387374
});
388375
});

0 commit comments

Comments
 (0)