2 décembre 2022

Comment détecter Safari en CSS

Clément Rivaille - Lead Developer

Suivi de comment créer un polyfill pour le gap d’une flexbox

Avant d’aller plus loin, voici la réponse :

	
@supports (background: -webkit-named-image(i)) {} /** Safari */
@supports not (background: -webkit-named-image(i)) {} /** Other browsers */
	


Boum, voilà. Avec ces sélecteurs, vous pouvez déclarer des règles spécifiquement pour Safari, de façon compatible avec n’importe quelle version (à ce jour, de la 3.1 à la 16). Très utile si vous devez résoudre des problèmes de rétro-compatibilité CSS, ou un comportement propre à Safari nécessitant un correctif.

Maintenant si vous souhaitez aller plus loin, laissez-moi vous raconter comment j’en suis arrivé à ce sélecteur. Je vais notamment détailler pourquoi j’en ai eu besoin, ce qu’il fait exactement, et pourquoi il est efficace.

Mais avant cela, je vais devoir vous parler de flexbox.

De la rétro-compatibilité du gap

gap est une propriété CSS permettant de spécifier, sur un parent flexbox ou grid, l’écart entre les enfants. C’est un outil très puissant pour l’agencement, bien plus que les marges ! En effet, définir des marges externes (margin) sur un élément le rend difficile à maintenir. Car les marges externes poussent le contenu autour sans discrimination, elles créent des effets de bord au moindre changement de contexte. C’est un enfer à gérer ! Le style appliqué à un élément ne devrait impacter que ce qui est à l’intérieur de l’élément. Ainsi margin est une propriété généralement à proscrire, en faveur de padding (quand l’espace interne a du sens) ainsi que, donc, gap. gap permet de faire dire au parent « Je veux que mes enfants, quels qu’ils soient, soient espacés de cette façon ». L’agencement devient alors bien plus simple à gérer, car un espace n’existe qu’entre deux éléments.

gap est supporté par tous les navigateurs depuis 2020, vous pouvez donc l’utiliser sans risque ! Ça ne coûte rien de rajouter un display: flex sur le conteneur, que celui-ci soit horizontal ou vertical. Vous avez tout à gagner à utiliser cette propriété. Mais qu’en est-il des anciens navigateurs ? Il est possible que vous souhaitiez cibler les versions qui ne supportent pas encore gap, comme ce fut mon cas Vous allez alors avoir besoin d’un remplacement pour gap. Bonne nouvelle, il y en a un : le sélecteur > * + *. Qui se traduit en « enfant direct succédant un autre enfant direct ». De cette façon vous pouvez cibler les enfants qui ont besoin d’un espace en haut (vertical) ou à gauche (horizontal) pour obtenir le bon espacement.

	
.flexContainer > * + * { margin-top: 8px;}
@supports not (background: -webkit-named-image(i)) {} /** Other browsers */  
	


Note : Il est aussi possible d’utiliser les sélecteurs :not(:last-child) ou :not(:first-child). Mais je privilégie la première solution car elle est plus courte et rapidement reconnaissable.

Cette solution n’est toutefois pas parfaite, car elle est moins performante que le gap. Par exemple, elle gère moins bien le wrap : sur le retour à la ligne (ou colonne), la marge est toujours présente et crée un décalage ! Un cas qui est géré par gap, qui permet même d’avoir un espace différent entre lignes et colonnes (row-gap et column-gap). Quand gap est supporté par le navigateur, il vaut mieux l’utiliser. Posons nous alors la question : comment détecter si gap est supporté ?

La solution est d’apparence simple : @supports(gap: 0) est exactement ce qu’il nous faut ! Malheureusement il y a un piège. Jetons un coup d’œil aux navigateurs qui supportent gap. En apparence, gap est supporté depuis 2017 par tous les navigateurs. Vous pouvez le voir en sélectionnant la vue « Date relative » de Can I Use (une fonctionnalité que j’ai découverte il y a peu, et extrêmement utile pour la rétro compatibilité comme vous allez le voir). Sauf que nous ne visons pas n’importe quel gap : on utilise celui pour flexbox. La compatibilité pour celui-ci est une tout autre histoire. On table plutôt sur du 2020 (un délai de 3 ans, eh oui), avec des écarts entre les navigateurs : Firefox le supportait dès fin 2018, et Safari a attendu jusqu’à 2021. Il y a donc des versions de navigateurs qui supportent gap, mais pas pour flexbox. Et le problème, c’est que même si le support est partiel, @support(gap: 0) renverra tout de même true. Et il n’y a aucun moyen de mesurer le support spécifique du gap pour flexbox ! Que faire alors ?

La compatibilité du gap de flexbox n’est pas arrivée en même temps pour tous les navigateurs

Ciblage de versions

On pourrait décider de ne plus du tout utiliser gap et passer par des margins, mais comme expliqué plus haut, on perd en fonctionnalité. C’est quand même dommage en 2023 d’impacter la majorité des styles des utilisateurs pour gérer la minorité des navigateurs d’avant 2020 ! L’idéal, c’est de gérer une version alternative pour ceux-là, quitte à ce qu’elle soit un peu moins jolie, pour pouvoir offrir aux navigateurs raisonnablement récents la version idéale du site. Mais comment faire sans @supports sur flexbox gap ?

Récapitulons, on veut être capable de détecter :

  • Les versions de Chrome et Edge avant la 84
  • Les versions de Firefox avant la 63
  • Les versions de Safari avant la 14.1
  • Les versions d’Opera avant la 70

À défaut de tester flexbox-gap… Peut-on trouver une autre propriété CSS qui correspond à ces versions ?

Ça demande un peu de recherche, mais ce n’est pas une tâche insurmontable. On a les versions des navigateurs et leurs dates. Il s’agit donc de parcourir les changelogs des versions ciblées ou les voisines, regarder l’actualité du web en 2020, et vérifier sur Can I Use si la date de compatibilité correspond à la période ciblée. La règle à suivre étant : on peut cibler une version légèrement supérieure à celle voulue, mais aucune en-dessous. En effet, si une version compatible avec gap utilise les margins à la place, c’est moins dramatique que si on essaye d’appliquer gap sur une version qui ne l’implémente pas encore.

Au bout de finalement peu de recherche, j’ai fini par trouver la valeur CSS revert (permettant d’annuler la valeur d’une propriété pour lui donner celle que l’élément aurait par défaut sans CSS). Elle est disponible en 2020 pile sur les versions 84 de Chrome et Edge, sur la 67 de Firefox, et la 74 d’Opera ! Pour ces navigateurs, on peut donc utiliser @suports(all: revert) pour déterminer avec fiabilité si le gap de flexbox est supporté !

Mais il y a un couac : Safari supporte revert depuis 2016 (version 9.1). On est carrément en-dessous de la version désirée ! Pour lui, la règle qui colle à la 14.1 serait plutôt la fonction lch (une nouvelle représentation de couleur). Problème : Safari est encore le seul à supporter cette fonction. Un @supports (color: lch(0% 0 0)) permettrait donc uniquement pour Safari de détecter le support de gap, mais exclurait totalement les autres navigateurs.

On a donc deux solutions différentes, pour « deux » navigateurs (bon, plus précisément, pour Safari et le reste du monde). Pour les appliquer, il faudrait écrire quelque chose dans ce genre :

	
.flexContainer > * + * {
 margin-top: 10px;
}
@supports ({is_safari?}) and (color: lch(0% 0 0)) {
 .flexContainer { gap: 10px; }
 .flexContainer > * + * { margin-top: 0; }
}
@supports (not {is_safari?}) and (all: revert) {
 .flexContainer { gap: 10px; }
 .flexContainer > * + * { margin-top: 0; }
}
	

Il nous faut donc résoudre le problème de l’intitulé : quelle règle CSS permet de détecter Safari, ou son absence ?

Les fausses pistes

Ce problème est loin d’être nouveau, et si vous le recherchez sur internet, vous tombez rapidement sur ce résultat :

	
_::-webkit-full-page-media, _:future, :root .safari_only {
 /* Your Safari only style here */
}
	

Premier résultat sur StackOverflow, dans un post qui a le mérite d’être très documenté et liste plein d’exemples et de variations ! Malheureusement dans notre cas, cette solution ne fonctionne pas. Pourquoi ? Et d’abord qu’est-ce que ça fait exactement tout ce charabia ?

Le sélecteur utilisé est découpé en trois parties, séparées par des virgules. Il s’agit donc en réalité de trois sélecteurs. Le .safari_only du troisième fait référence à une classe créée pour l’exemple, il peut donc être remplacé par n’importe quoi, pas besoin de s’étendre dessus. La clé se situe sur les deux premiers sélecteurs, s’appliquant sur un élément _. Que veut dire l’underscore en CSS ? Absolument rien (je vous épargne la longue recherche). CSS l’interprète comme si l’on ciblait un tag div ou span, sauf qu’ici il cherche un tag _, qu’il ne trouvera jamais car ça n’existe pas dans le DOM. Et c’est voulu, car ce qui compte, c’est les pseudo-sélecteurs, ::-webkit-full-page-media et :future. Comme vous le devinerez aisément, ceux-ci n’existent que sous Safari (ainsi que quelques versions de Chrome, mais jamais en même temps, d’où la présence des deux). Et voilà la subtilité : lorsque CSS rencontre une syntaxe qu’il ne reconnaît pas, il ignore le bloc entier et passe au suivant. C’est l’une des forces d’HTML et de CSS, ils ne plantent jamais même en cas d’erreur. Ici, ça nous permet de forcer CSS à lire la ligne sur Safari, et l’ignorer sur les autres navigateurs ! Plutôt ingénieux.

Seulement ça ne convient pas à notre besoin. Déjà parce qu’on ne peut pas en faire une négation. Comme ce mécanisme fait « planter » silencieusement CSS, même utiliser un :not dessus aurait le même effet. Or on a besoin aussi de sélectionner exclusivement les autres navigateurs que Safari (le StackOverflow présentant cette solution propose aussi des solutions pour Chrome, mais  elles ne s’appliquent pas à Firefox ni Opera, et reposent sur un même type de logique). L’autre problème, ce que cette solution est un sélecteur, et que l’on cherche à utiliser @supports. Il ne nous est donc pas possible de combiner les deux.

Quelque part ailleurs sur internet, il y a un article nommé CSS @supports rules to target only Firefox / Safari / Chromium. Le titre est prometteur, et sa solution d’apparence élégante !

	
@supports selector(:nth-child(1 of x))
	

On a bien un filtre dans @supports, et une règle qu’on peut inverser avec not ! Cette syntaxe :nth-child pour sélectionner le nième enfant parmi x n’existe en effet que sous Safari.  Problème résolu ?

Pas tout à fait malheureusement. Car ce filtre utilise la fonction selector qui teste la compatibilité d’un sélecteur avec le navigateur. Un outil très utile à avoir à votre ceinture. Mais avec une mise en garde cependant : il n’est supporté que depuis 2020. Sur la 81 de Chromium, la 14.1 de Edge, et la 69 de Firefox. Ça ne nous arrange pas du tout ! Ce filtre exclue trop de versions pour lesquelles on a besoin de dissocier Safari (et hélas il n’est pas possible de tester @supports (selector)).

Mais dans ce même article, il y a un tweet. Et ce tweet est peut-être le seul endroit sur internet où se trouve la parfaite solution ! (tant que Twitter tient toujours debout)

La solution

	
@supports (background: -webkit-named-image(i)) {} /** Safari */
@supports not (background: -webkit-named-image(i)) {} /** Other browsers */
	

-webkit-named-image est une fonction non standard ajoutée à Safari depuis son premier fork de Chromium en 2008. Il est peu mentionné, mais encore présent sur les versions récentes de Safari ! Inversement, il n’y a absolument aucune chance qu’il soit ajouté sur d’autres navigateurs car c’est une fonction purement propre à Apple. On a donc là le sélecteur idéal pour dissocier Safari des autres navigateurs !

Cela nous amène donc à la solution finale pour gap :

	
.flexContainer > * + * {
 margin-top: 10px;
}
/* Safari with gap */
@supports (background: -webkit-named-image(i)) and (color: lch(0% 0 0)) {
 .flexContainer { gap: 10px; }
 .flexContainer > * + * { margin-top: 0; }
}
/* Other browsers with gap */
@supports (not (background: -webkit-named-image(i))) and (all: revert) {
 .flexContainer { gap: 10px; }
 .flexContainer > * + * { margin-top: 0; }
}
	

Note : Il est possible de faire encore plus court en n’utilisant qu’une seule ligne avec un OR. Cependant cela rendrait le code plutôt indigeste à lire, avec une condition très longue remplie de sélecteurs obscurs et de négation. Il est au final plus clair d’avoir deux blocs distincts “Safari” / “Other browsers”, accompagnés de commentaires pour les identifier.

Bonus, avec PostCSS, il est possible définir des mixins pour réutiliser ce code :

	
@define-mixin margin-children-column $height {
 & > * + * {
   margin-top: $height;
 }
}
@define-mixin row-gap $gapHeight {
 @mixin margin-children-column $gapHeight;
 @supports (background: -webkit-named-image(i)) and (color: lch(0% 0 0)) {
   row-gap: $gapHeight;
   @mixin margin-children-column 0;
 }
 @supports (not (background: -webkit-named-image(i))) and (all: revert) {
   row-gap: $gapHeight;
   @mixin margin-children-column 0;
 }
}
.flexContainer {
 dispplay: flex;
 flex-direction: column;
 @mixin row-gap 10px;
}
	

Épilogue

Comme vous pouvez le constater, parvenir à cette solution en apparence simple a nécessité un long périple ! Un parcours fait de nombreuses tentatives, expérimentations et recherches, qui ne sont ici que survolées (il y aurait encore beaucoup à dire sur ce que PostCSS permet et ne permet pas). Les distributeurs de navigateurs sont peu enclins à s’accorder sur des standards ou même des documentations pour faciliter le développement, et ce genre de connaissance doit encore se partager comme des arcanes secrètes.

Ce que l’on peut retenir de cette mésaventure, c’est :

  • Vérifiez bien la compatibilité d’une fonctionnalité CSS : un résultat positif peut cacher un support seulement partiel
  • Can I Use peut est très utile pour placer chronologiquement les versions des navigateurs, et ainsi mesurer et cibler la compatibilité
  • @supports est le meilleur outil pour détecter la compatibilité, mais il a lui-même ses contraintes
  • La rétro-compatibilité a souvent un coût insoupçonné, particulièrement avec Safari qui s’éloigne très souvent des standards des autres navigateurs
  • Notez @supports (background: -webkit-named-image(i)) quelque part parce que c’est vraiment bien planqué