Dernièrement, je suis tombé sur un problème de CSS qui m’a demandé de fouiller un peu la notion de Stacking Context ( en français, le contexte d’empilement ) en HTML.

Le problème

Quelques screen-shots

Je suis en plein dans React. Je réalise un petit projet d’application. Pour explorer la techno, et tomber sur différent problèmes classiques, j’ai préféré ne pas utiliser de lib de composants, mais de coder un maximum from scratch.

Dans mes prérégrinations, j’ai eu besoin de développer un composant du type menu-sur-le-coté-qui-slide-quand-on-clique-sur-un-bouton. En Material Design, c’est un “Navigation Drawer”. J’ai vu ça aussi appelé “SideBar”.

Je voudrais avoir un composant tout en un. En instanciant mon composant, je récupère à la fois le bouton permettant d’ouvrir/fermer la SideBar et la SideBar elle même qui viendra se placer sur le coté droit ou gauche, en fixed. Pas de branchement à faire soi même, pas de positionnement, pas de state à penser. Plus de temps et d’énergie à passer sur les features de son app et moins sur les branchement et l’écriture d’un mille-deux-centre-quatre-vint-seizième état open/closed.

Voici une illustration du résultat attendu.

SideBar Fermée

SideBar Fermée

SideBar Ouverte

SideBar Ouverte

Pour fermer le tout on clique sur la croix ou sur la partie grise qui couvre l’espace de l’écran où la SideBar n’est pas.

Un peu de code

Voilà comment j’ai organisé mon code dans un premier temps.

J’ai un composant SideBarKit qui contient un bouton SideBarActivator et la SideBar

<div className="side-bar-kit">
        <SideBarActivator clicked={this.state.displayed} onClicked={this.onBurgerClicked} icon={this.props.icon} />
        {this.state.displayed &&
          <SideBar right={this.props.right} title={this.props.title}>     
            {this.props.children}
          </SideBar>
        }
        {this.state.displayed &&
          <SideBarOverlay right={this.props.right} onClicked={this.onBurgerClicked} />
        }
</div>

Mon SideBarKit est insérée dans le header de l’application, positionné par du flexbox.

<header className="row">
    <div className="col-xs-2">
        <SideBarKit>...childrends...</SideBarKit>
    </div>
    ...other cols-....
</header>

Qui correspondent aux éléments suivant sur l’écran :

Comme on le voit ici, j’ai choisi d’avoir un bouton nommé SideBarActivator pour ouvrir et fermer la SideBar. La fermeture est aussi possible en cliquant sur l’Overlay.

L’overlay est ici un div noir, qui couvre toute la surface du viewport.

.sidebar-overlay {
  background: rgba(0, 0, 0, 0.4);
  width: 100vw;
  height: 100vh;
}

Pour avoir la SideBar accessible en haut, l’overlay en dessous et le bouton accessible pour ouvrir et fermé j’ai choisi les z-index suivants.

  • Overlay : z-index : 8;
  • Sidebar : z-index : 9;
  • SidebarActivator : z-index : 10;

Maintenant ajoutons un deuxième SideBarKit de l’autre coté du header !

Le deuxième SideBarKit

Deuxieme SideBarKit

Nous voici dans un petit snafu : le deuxième SideBarActivator (à droite) reste au dessus de mon premier SideBarOverlay (à gauche).

Eh bien, oui, duh, c’est normal ! Le z-index de mon Overlay est inférieur à celui de mon Activator… Duh !

J’avais réglé ça comme ça pour que le même bouton puisse servir à ouvrir et fermer le même SideBar. Il fallait donc que le bouton reste au dessus du SideBar et donc au dessus de l’Overlay. Les deux Overlay droite et gauche ont le même z-index donc…les deux SideBarActivator droite et gauche sont au dessus de ces Overlay.

Je vais changer ça : j’aurais un bouton pour ouvrir, en dessous des Overlay , et un bouton pour fermer, au dessus des Overlay.

Du coup, l’Overlay 1 sera au dessus du bouton SideBarActivator2 pour ouvrir SideBar2 (et accessoirement au dessus du SideBarActivator1 mais il sera remplacé par un nouveau bouton destiné à fermer le tout)

Je code ça, je z-index correctement et…

Fuque ! Même chose !

Deuxieme SideBarKit-2

et pourtant…

Quelle est cette sorcellerie ?

Interlude : où l’on parle de stacking context

Superposition : Règles de base

En HTML, le Stacking Context, est une notion permettant d’expliquer les règles d’empilement des différents éléments : quel élément apparait au dessus de quel élément dans la page.

On peut le voir comme un conteneur ou une couche.

La racine du document HTML définit un premier contexte : tous les éléments enfants sont placés selon les règles du flow HTML. En cas de superposition de deux éléments, c’est l’élément le plus loin dans le document (littéralement le plus bas dans le code HTML) qui se retrouve au dessus. Si on veut forcer un autre ordre de superposition, on utilise des z-index.

N. D. A. : je vous donne ici la version ultra simplifiée. Il y a plein de petites exceptions et cas limites.

Exemple : pas de superposition
<html>
    <body>
	    <div style="background:red;width:100px;height:100px;"></div>
		<div style="background:blue;width:100px;height:100px;"></div>
		<div style="background:green;width:100px;height:100px;"></div>
	</body>
</html>

Exemple: superposition

Les blocks se superposent dans l’ordre de leur position dans le document. Le plus près du début est en dessous.

<html>
    <body style="position:relative;">
	    <div style="background:red;width:100px;height:100px;position:absolute;"></div>
		<div style="background:blue;width:100px;height:100px;position:absolute;top:30px;left:30px"></div>
		<div style="background:green;width:100px;height:100px;position:absolute;top:60px;left:60px"></div>
	</body>
</html>


Exemple : z-index

En jouant sur les z-index, on modifie l’ordre de superposition par défaut.

z-index par défaut si pas précisé = auto.

Deux blocks de même z-index sont superposés selon la règle par défaut : premier arrivé en dessous des autres.

Superposition : ca se complique

Les choses se “compliquent” lorsqu’on sort un élement du flow normal avec par exemple position : absolute, relative, fixed et z-index != auto. Les choses se compliquent non pour l’élement qui sort du flow mais pour ses enfants.

L’elément qui sort du flow est placé (en superposition) selon les règles “normales” par rapport à la racine de son propre stacking context. SI c’est le premier de sa branche depuis la racine à être absolute, alors cette racine est la racine du document.

Par contre, pour ses enfants, il créée un nouveau stacking context.

Les enfant seront placés en superposition en suivant les mêmes règles (ordre html + z-index) par rapport à leur parent position:absolute et non plus par rapport à la racine. L’ordre du html et les z-index auront une influence DANS ce stacking contexte, mais les élements n’auront que faire de l’ordre HTML et des z-index des éléments en dehors de leur stacking context.

On peut voir ces stacking contexte comme différents couches : à partir de la racine du document, on créé un couche par élément, qui se superposent selon l’ordre et les z-index. Dans chaque élément positionné (c’est à dire avec position != static) on créée des sous-couches supplémentaires, contenues dans la couche de l’élément positionné .

Cela signifie qu’il est impossible, en jouant sur les z-index, de placer un élément enfant d’un élément positionné (avec z-index!=auto) par rapport à l’élément enfant d’un autre élément positionné (avec z-index!=auto) . Les z-index sont complétement décorellés dans ce cas.

Exemple
<html>
    <body style="position:relative;">
	    <div style="background:red;width:100px;height:100px;position:absolute;z-index:3">
		    <div style="background:gray;width:30px;height:30px;position:absolute;bottom:10px;right:10px;z-index:2"></div>
		    <div style="background:aquamarine;width:30px;height:30px;position:absolute;bottom:20px;right:20px;z-index:0"></div>
		</div>
		<div style="background:blue;width:100px;height:100px;position:absolute;top:30px;left:30px;z-index:0">
		</div>
		<div style="background:green;width:100px;height:100px;position:absolute;top:60px;left:60px;z-index:0">
		    <div style="background:black;width:30px;height:30px;position:absolute;top:15px;left:15px;z-index:11"></div>
		    <div style="background:white;width:30px;height:30px;position:absolute;top:20px;left:20px;;z-index:10"></div>
		</div>
	</body>
</html>

Le div rouge, absolute, créé à l’intérieur un nouveau stacking context. Les div enfant, aquamrine et gray, sont placé les un par rapport aux autres selon leur z-index relatifs. Par contre, ils restent au-dessus de TOUS les autres éléments de la page, même les petits div noir et blanc qui on un z-index plus grand, parceque leur parent, leur COUCHE, est au dessus du div vert et bleu.

Les div noir et blanc, on un z-index important mais restent en dessous de aquamarine et gris, parceque leur parent, le vert, est en dessous du rouge.

Superposition : Règles importantes

  • Position absolute + z-index auto tous les enfants restent dans le mem stacking contexte.
  • d ‘autres Stacking Contexts sont créés dans les cas suivants : https://developer.mozilla.org/fr/docs/Web/CSS/Comprendre_z-index/Empilement_de_couches

  • z-index n’a aucune effet sur un élément qui n’est pas positionné (ie position != static). Donc si mon SideBarOverlay 1 est position : static avec z-index : 8, tout autre élément sera stacké par dessus, sans prendre en compte le z-index.

Retour à mes SideBar

Analyse

Hypothèse 1

Il s’avère que cette hypothèse était fausse. SideBarKit n’est pas z-indexé, donc son z-index est à auto et aucun Stacking Context n’est créé. Donc le problème vient d’ailleurs. Je garde cette analyse dans un coin quand même au cas où quelqu’un (peut-être moi) tombe sur le même cas de figure avec des z-index différent de auto. Dans ce cas, toute cette suite se vérifie.

Les deux SideBarKit sont placés en absolute et ont un z-index différent de auto. ils définissent donc un stacking contexte chacun.

Les z-index utilisés sur les éléments DANS les SideBarKit permettent donc de définir leurs positions relatives mais je ne peux pas définir la position de SideBarActivator 2 par rapport à SideBarOverlay 1 avec des z-index index.

En l’occurence, les deux SideBarKit sont dans le meme stacking contexte (le principal) et le deuxième SideBarKit est arrivé après le premier donc il est affiché par dessus. Tous les éléments qu’il contient aussi sont donc au dessus de tous les éléments du premier, quelque soit leurs z-index. Donc SideBarActivator 2 est au dessus de SideBarOverlay 1.

Par contre comme SideBarKit 2 est après SideBarKit 1 dans le document, il est au dessus et donc le SideBarOverlay 2 est au dessus de SideBarActivator 1.

Ceci étant clarifié, comment fais-je pour avoir le comportement voulu, c’est à dire pour que Overlay 1 au dessus de activator 2 pour que mon utilisateur puisse cliquer sur SideBarOverlay 1 pour fermer SideBar 1.

Solutions

Je me rappelle avoir utilisé un comportement semblable avec Vuetify sous VueJS.

J’ai commencé par aller regarder par là-bas

Vuetify traite les choses un peu différemment. Il n’y a pas de composant littéralement équivalent à ce que j’essaie de produire. Le navigation drawer n’est pas libré avec un “activator”, il faut instancier les deux de manière indépendantes.

Je voudrais vraiment produire un tout-en-un, pour avoir une capacité de réutilisabilité la plus rapide possible, mais je peux regarder comment ils ont gérés l’overlay et les activators dans cet exemple.

Leur solution a été d’avoir un Overlay unique pour TOUS les navigation drawer placé au plus près possible de la racine. Cet Overlay est ajouté au dom via un mixin Overlayable présent dans le NavigationDrawer. Dans le cas du navigation drawer, il est ajouté comme premier enfant du div v-app avec un z-index=6 qui est le premier élément de notre application vuetify.

Je pense que cette position est censée lui garantir de superposer tous les éléments de l’application vuetify à condition que leur z-index reste < 6. Les éléments présent dans d’autres stack contexte seront également sous l’overlay à condition ques e leur parent-racine du stack context soit z-index<6.

Ensuite les navigation drawer sont instanciés comme enfant de la navbar mais avec un position:fixed, ce qui les place relativement au viewport directement. leur z-index est fixé à 6, comme l’overlay. Comme l’overlay est plus haut, les nav drawer sont affichés par dessus l’overlay.

Les boutons permettant d’ouvrir et fermer les navbar sont inctanciés dans le header. Leur stack contexte est le stack context principal, et donc ils sont sous l’overlay car leur z-index est 0.

La version Vuetify a donc un overlay unique, placé au plus près de la racine de l’app, et tous les NavigationDrawer et boutons liés à leur ouverture/fermeture dans le même stack context.

Dans mon cas il y a un overlay par SideBarKit, et les overlays et SideBars sont enfant du SideBarKitqui est lui-même en position relative avec un z-index différent de auto. Les SideBar et overlays de chaque SideBarKit sont donc dans un Stack Context différent.

Je ne pourrais donc jamais en jouant sur les z-index faire remonter Overlay1 au dessus de SideBarActivator 2. Ma seule solution est donc de sortir l’Overlay comme dans Vuetify.

Ensuite, pour garder les Activator en dessous de l’overlay je suis obligé de garder le z-index de leur parent (SideBarKit) en dessous de celui de l’overlay. Si les SideBar restent enfant de SideBarKit je ne pourrais donc jamais les faire remonter au dessus de l’overlay. Je dois donc les sortir aussi pour les mettre dans le même stack context que l’overlay.

Et finalement il me reste dans les SideBarKit seulement les boutons d’activation, les SideBarActivator.

Donc je n’ai plus un composant stand-alone contenant activator, overlay et sidebar. J’ai un composant SideBar et un composant activator complètement indépendant, que le développeur devra brancher lui même. Ca laisse plus de liberté mais c’est aussi plus de travail. Des décisions à prendre, de la doc à lire et du temps et de l’énergie qui n’est pas passé sur l’appli elle même. Ce n’était pas mon but premier. J’aurais voulu avoir du tout en un.

L’overlay sera instancié par la SideBar comme dans Vuetify.

Crééer un ovelay dans le dom en javascript, pas clairement dans l’arbre de composant est un peu dangereux aussi : ca peut crééer des effets de bord avec le style de l’appli, des classes ou des z-index… et surtout, ce n’est pas explicite en instanciant le composant, donc ca peut créer des comportement enigmatiques pour celui qui ne connait pas les détails de l’implémentation des composants.

Bon si j’accepte ces limitations, je peux essayer quelque chose de semblable à vuetify : un overlay unique pour les deux que je créé en js en manipulant le dom et en regardant s’il n’est pas déjà créé par une autre SideBar.

Les deux SideBar en fixed pour avec z-index supérieur a celui de l’overlay.

Les boutons indépendants.

Je vois pas de solution pour garder tout en un et avoir le comportement recherché, a moins de peut-être créer les SideBar à la volée comme l’Overlay. Mais ça rends l’arbre encore plus opaque et peu explicite et donc le risque d’effet de bord, de collision avec le code de l’utilisateur de mon composant plus important.

Hypothèse 2

En fait non, les deux SideBarKit ne sont pas z-indexés : leur z-index est donc auto, et aucun nouveau Stacking Context n’est créé. Alors pourquoi SideBarActivator 2 reste au dessus de SideBarOverlay 1 mais overlay 2 passe au dessus de actiator 1 si tout est dans le même Stacking Context ?

C’est parceque z-index ne fonctionne que sur les éléments positionnés !

Et il s’avère que le z-index de mon overlay n’est pas computé, parceque le SideBarOverlay 1 (qui n’a pas la classe .right) n’est pas positionné ! C’était donc un heureux hasard que le bouton SideBarActivator 1 reste au dessus de Overlay 1 dans mon premier cas avant la première correction à cause de l’ordre des composants dans le html.

En fait, mon overlay gauche (1) n’est pas positionné, mais l’overlay droite, oui (à cause d’un .right qui ajoute position absolue). Du coup, SideBarActivator 1 reste bien en dessous de SideBarOverlay 2.

Solution

Pour avoir le comportement désiré, on ajoute position:relative sur la classe .overlay pour que son z-index soit pris en compte.

Il ne reste plus qu’à ajouter le bouton de fermeture différent du bouton d’ouvertue, dont le z-index sera bien choisi pour rester au dessus de SideBar et de SideBarOverlay et qui n’apparaitra que si la SideBar est ouverte.

Il sera également au dessus de Overlay 2 mais on s’en fou car il sera sur la gauche et Overlay 2 sur la droite. L’important c’est que le bouton d’ouvertue 2 soit en dessous de Overlay 1.

J’ai même pas besoin d’un deuxième bouton : je change juste le z-index du bouton du SideBarActivator quand la SideBar associée est ouverte.

Conlusion

En se plongeant dans la norme pour comprendre le comprotement des éléments HTML au sein d’un Stacking Context, j’ai corrigé mon problème en 2 lignes de code sans compromis sur mon architecture.

J’ai réussi a encapsuler le comportement voulu (un composant => une bouton + une sidebar, pas de branchement, pas de positionnement) dans un seul composant.

Yippee-ki-yay, motherfucker

Ma seule limitation : je ne peux pas mettre de z-index sur les SideBarKit sous peine de créer un nouveau Stacking Context et de me retrouve dans la situation de l’hypothèse 1. A préciser donc dans une hypothétique doc de l’hypothétique distribution de ce composant !