Click sur un pseudo-element

Comment déterminer en javascript qu'un événement click a eu lieu sur un pseudo-élément et pas ailleurs dans l'élément parent ?

Problématique

Les pseudo-éléments sont très utiles dans de nombreux cas, que ce soit en typographie ou en design. Le problème, c'est qu'ils ne font pas partis du DOM ; impossible donc d'agir sur eux en javascript. Comment dés lors surveiller des actions qui leur sont liées comme le click ? La problématique peut aussi s'étendre au fait que l'on souhaite parfois définir uniquement une zone particulière d'un élément sensible à l'interaction souris.

Première solution : BoundClick

Après une petite demi-heure de reflexion / codage, et une autre demi-heure de test / debug, je suis arrivé à quelque chose d'à peu près satisfaisant.

  • Le code reste très simple : il faudrait ajouter des tests de type pour les paramètres par exemple.
  • La technique n'est pas limitée à la gestion d'événements sur les pseudo-éléments ; elle peut aussi être utilisée pour cibler toute zone particulière d'un élément.
  • Elle n'est sans doute pas applicable tel quel dans tous les cas de figures ; je n'ai testé que ceux dont j'avais besoin.
  • Elle a été validée sous Firefox 17, Opera 12.10, Chrome 23 et Internet Explorer 9/10.
define(function () {
    'use strict';
    /**
     *  Objet permettant de limiter la zone d'action d'un événement click
     *  sur un élément HTML. Utile par exemple pour cibler les
     *  pseudo-éléments.
     *
     *  La zone de click autorisée est définie par une série de propriétés de 
     *  position 'top', 'left', 'bottom', 'right' et des propriétés de taille 
     *  'width' et 'height'. Les propriétés de position sont relatives à 
     *  l'élément ciblé par le click. 
     *  
     *  La zone de click est par défaut positionnée en top = 0 et left = 0.
     *
     *  Si les tailles ne sont pas fournies ou si elles sont égales à -1, on 
     *  considère l'ensemble de l'élément ciblé comme autorisé, moins les 
     *  'marges' définies par les propriétés de position.
     *
     *  Si 2 propriétés de position d'un même axe (top et bottom ou left et 
     *  right) sont présentes en même temps que la propriété de taille 
     *  correspondante (height ou width), alors la taille prend le pas sur la 
     *  propriété de position opposée pour définir la zone de click.
     *
     *
     *  @example
     *
     *  ----
     *      |-----------------------------------------------|
     *      |xxxxx                                          |
     *      |xxxxx                                          |
     *      |xxxxx                                          |
     *      |xxxxx                                          |
     *      |-----------------------------------------------|
     *  ----
     *  bounds = { width: 10 }
     *
     *  ----
     *      |-----------------------------------------------|
     *      |                                               |
     *      |   xxxxx                                       |
     *      |   xxxxx                                       |
     *      |                                               |
     *      |-----------------------------------------------|
     *  ----
     *  bounds = { top: 5, left: 5, width: 10, height: 10 }
     *
     *  ----
     *      |-----------------------------------------------|
     *      |                                               |
     *      |                                       xxxxx   |
     *      |                                       xxxxx   |
     *      |                                               |
     *      |-----------------------------------------------|
     *  ----
     *  bounds = { right: 5, top: 5, width: 10, height: 10 }
     *
     *  ----
     *      |-----------------------------------------------|
     *      |                                               |
     *      |   xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx   |
     *      |   xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx   |
     *      |                                               |
     *      |-----------------------------------------------|
     *  -----
     *  bounds = { top: 5, left: 5, right: 5, bottom: 5 }
     *
     *  ----
     *      |-----------------------------------------------|
     *      |   xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx|
     *      |   xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx|
     *      |   xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx|
     *      |   xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx|
     *      |-----------------------------------------------|
     *  ----
     *  bounds = { left: 5 }
     *
     *  @constructor
     *  @param {HTMLElement} target Elément sur lequel écouter le click.
     *  @param {Object<String, int>} bounds Objet décrivant la zone de click autorisée. 
     *  @param {Function} success Fonction appelée lorsque le click a lieu dans la 
     *  bonne zone. Elle reçoit en paramètre l'événement correspondant au click.
     *  @param {Function} [validator] Fonction facultative de test appelée à chaque
     *  click pour vérifier si l'analyse doit être exécutée. Elle reçoit en paramètre
     *  l'événement correspondant au click ; si elle retourne ``false``, l'analyse 
     *  ne se fera pas.
     */
    var BoundClick = function (target, bounds, success, validator) {
        /**
         *  Liste des validateurs de position sur les axes x et y.
         *
         *  @private
         *  @type Object<String, String>
         */
        this.validators = {};
        
        /**
         *  Zone clickable
         *
         *  @private
         *  @type Object<String, int>
         */
        this.bounds = this.defineBounds(bounds);
        
        /**
         *  Callback lorsque le click est dans la zone autorisée.
         *
         *  @private
         *  @type Function
         */
        this.success = success;
        
        /**
         *  Callback pour vérifier si l'élément cliqué est valide.
         *
         *  @private
         *  @type Function
         */
        this.validate = validator;
        
        this.target = target;
        this.enable();
    };

    /**
     *  Objet de données associant les propriétés de position et la propriété de
     *  taille d'un même axe.
     *
     *  @private
     *  @static
     *  @type Object
     */
    BoundClick.AXIS = {
        x: {pos: 'left', opp: 'right', size: 'width'},
        y: {pos: 'top', opp: 'bottom', size: 'height'}
    };

    BoundClick.prototype = {
    
        // Fix constructor
        constructor: BoundClick,
        
        /**
         *  Ecouteur d'événement.
         *  @protected
         *  @param {Event} e DOM event.
         */
        handleEvent: function (e) {
            var method = 'on' + e.type;
            if (this[method]) {
                this[[method|e]];
            } else {
                console.warn('no handler for %s event', e.type);
            }
        },
        
        /**
         *  (Ré)activer la gestion de la zone de click.
         */
        enable: function () {
            this.target.addEventListener('click', this, false);
        },
        
        /**
         *  Désactiver la gestion de la zone de click.
         */
        disable: function () {
            this.target.removeEventListener('click', this, false);
        },
        
        dispose: function () {
            this.disable();
            this.target = null;
            this.validator = null;
            this.success = null;
            this.bounds = null;
        },
        
        /**
         *  Définir les données de la zone de click valide.
         *
         *  @private
         *  @param {Object<String, int>} bounds  Données fournies.
         *  @return {Object<String, int>} Les données corrigées / complétées.
         */
        defineBounds: function (bounds) {
        
            var key, axis, data;

            // props to number
            for (key in bounds) {
                if (isNaN(bounds[key])) {
                    bounds[key] = 0;
                }
            }
            // define validators
            for (axis in BoundClick.AXIS) {
                data = BoundClick.AXIS[axis];
                if (!bounds.hasOwnProperty(data.size) || bounds[data.size] === 0) {
                    bounds[data.size] = -1;
                }
                if (!bounds.hasOwnProperty(data.opp)) {
                    if (!bounds.hasOwnProperty(data.pos)) {
                        bounds[data.pos] = 0;
                    }
                    this.validators[axis] = 'natural';
                } else {
                    if (bounds.hasOwnProperty(data.pos)) {
                        this.validators[axis] = 'both';
                    } else {
                        this.validators[axis] = 'opposed';
                    }
                }
            }
                
            return bounds;
        },
        
        /**
         *  Tester si le click a eu lieu dans la bonne région par le positionnement
         *  "naturel" (top, left).
         *
         *  @private
         *  @param {String}         axis    L'axe analysé (x|y)
         *  @param {int}            value   La valeur à anlyser.
         *  @param {TextRectangle}  bounds  Données de la boîte de l'élément cliqué.
         */
        natural: function (axis, value, bounds) {
            var data = BoundClick.AXIS[axis],
                pos = bounds[data.pos] + this.bounds[data.pos];
            if (value < pos) {
                return false;
            }
            if (this.bounds[data.size] === -1) {
                return true;
            }
            if (value > pos + this.bounds[data.size]) {
                return false;
            }
            return true;
        },
        
        /**
         *  Tester si le click a eu lieu dans la bonne région par le positionnement
         *  "opposé" (bottom, right).
         *
         *  @private
         *  @param {String}         axis    L'axe analysé (x|y)
         *  @param {int}            value   La valeur à anlyser.
         *  @param {TextRectangle}  bounds  Données de la boîte de l'élément cliqué.
         */
        opposed: function (axis, value, bounds) {
            var data = BoundClick.AXIS[axis],
                pos = bounds[data.pos] + bounds[data.size] - this.bounds[data.opp];
            if (value > pos) {
                return false;
            }
            if (this.bounds[data.size] === -1) {
                return true;
            }
            if (value < pos - this.bounds[data.size]) {
                return false;
            }
            return true;
        },

        /**
         *  Tester si le click a eu lieu dans la bonne région par le 
         *  positionnement "double" (top-bottom, left-right).
         *
         *  @private
         *  @param {String}         axis    L'axe analysé (x|y)
         *  @param {int}            value   La valeur à anlyser.
         *  @param {TextRectangle}  bounds  Données de la boîte de l'élément cliqué.
         */
        both: function (axis, value, bounds) {
            return this.natural(axis, value, bounds) 
                    && this.opposed(axis, value, bounds);
        },

        /**
         *  Ecouteur d'événement click.
         *
         *  @protected
         *  @param {Event} e DOM event.
         */
        onclick: function (e) {
            if (this.validate && this.validate(e) === false) {
                return;
            }
            var bounds = e.target.getBoundingClientRect();
            if (!this[this.validators.x]('x', e.clientX, bounds)) {
                return;
            }
            if (!this[this.validators.y]('y', e.clientY, bounds)) {
                return;
            }
            this.success(e);
        }
    };

    // export
    return BoundClick;

});
BoundClick.js

A noter que l'objet est déclaré ici en tant que module AMD ; le code peut évidemment être utilisé sans cela puisqu'il n'a aucune dépendance, si ce n'est un navigateur moderne.

Exemple d'utilisation

  • Cibler le pseudo-élément :before des items de liste à puces.
  • Changer la couleur du texte de l'item sur le click.
<ul id="demo-target">
    <li>Item 1</li>
    <li>Item 2</li>
    <li>Item 3</li>
    <li>Item 4</li>
    <li>Item 5</li>
</ul>
Code HTML de l'exemple.
#demo-target > li {
    list-style: none;
}
#demo-target > li:before {
    content: "";
    display: inline-block;
    margin: 0 5px 0 0;
    width: 14px;
    height: 14px;
    line-height: 1;
    background: #00f;
}
Code CSS de l'exemple. Nous faisons du pseudo-élément :before des items de simples carrés bleus.
var bounder = new BoundClick(
    document.getElementById('demo-target'),
    { width: 14, height: 14 },
    function (e) { 
        if (!e.target.style.color) {
            e.target.style.color = '#f00';
        } else {
            e.target.style.color = '';
        }
    },
    function (e) { 
        return e.target.nodeName.toLowerCase() === 'li'; 
    }
);
Gérer le click limité sur la zone du pseudo-élément :before des items de la liste à puces.
  • Item 1
  • Item 2
  • Item 3
  • Item 4
  • Item 5
Démonstration : la zone bleue est l'unique zone cliquable des items.

Autre technique : offsetX / offsetY

On peut obtenir quelque chose de comparable avec un code plus simple, qui n'aura évidemment pas toutes les fonctionnalités d'un objet comme le BoundClick mais qui peut répondre à des besoins courants. Il s'agit ici de se baser sur les propriétés offsetX et offsetY des événements souris. Si on reprend l'exemple ci-dessus, on pourrait écrire queqlue chose comme :

// Click listener
const onClick = function (e) {
    let li = e.target;
    if (li.nodeName.toLowerCase() === 'li' && e.offsetX < 14 && 
            e.offsetY < 14) {
        if (!li.style.color) {
            li.style.color = '#f00';
        } else {
            li.style.color = '';
        }
    }
};
// element
const element = document.getElementById('demo-target');
// listen
element.addEventListener('click', onClick);
Ecoute du click sur la liste.
  • Item 1
  • Item 2
  • Item 3
  • Item 4
  • Item 5
Démonstration : la zone bleue est l'unique zone cliquable des items.

Ressources et références

HICKSON, Ian. CSS3 Generated and Replaced Content Module. W3C, . 4. Pseudo-elements

element.getBoundingClientRect. Mozilla Developer Network,

MouseEvent.offsetX. Mozilla Developer Network,

MouseEvent.offsetY. Mozilla Developer Network,

Historique

2013-01-02
  • add création de l'article.
2017-06-08