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;
9898const 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.
102101const DisclosureState = {
103102 slug : window . location . pathname ,
104103 cache : null ,
@@ -107,18 +106,25 @@ const DisclosureState = {
107106// --- DAL (Data Access Layer) ---
108107const 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) ---
135141const 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.
305288const 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