legacy/outils/listeDeroulante/ListeDeroulante.js

import { j3pAddElt, j3pElement, j3pEmpty, j3pFreezeElt } from 'src/legacy/core/functions'

import { j3pAffiche } from 'src/lib/mathquill/functions'

// et notre css
import 'src/legacy/outils/listeDeroulante/listeDeroulante.scss'
import { barreZone } from 'src/legacy/outils/zoneStyleMathquill/functions'
import { getZoneParente } from 'src/lib/core/domHelpers'
import { afficheMathliveDans } from 'src/lib/outils/mathlive/display'

/**
 * L’outil listeDeroulante créé un "select" like où on peut mettre dans chaque item du LaTex, des images, etc.
 * Il faut passer par ListeDeroulante.create() et pas new ListeDeroulante(…)
 * @class
 */
class ListeDeroulante {
  /**
   * @constructor
   * @param choices
   * @param onChange
   * @param alignmathquill
   * @param boutonAgauche
   * @param choix0
   * @param sensHaut
   * @param decalage
   * @param {boolean} displayWithMathlive true si on affiche les item en LateX via Mathlive
   */
  constructor (choices, { onChange, alignmathquill, boutonAgauche, choix0, sensHaut, decalage, displayWithMathlive } = {}) {
    if (typeof this !== 'object') throw Error('Constructeur qui doit être appelé avec new')
    if (arguments.length > 2) throw Error('nombre d’arguments invalides')
    if (!Array.isArray(choices)) throw Error('Il faut passer une liste de choix')

    /**
     * Le type de liste, peut valoir ld|zsm1|zsm2|zsm3, actuellement utilisé par ZoneStyleAffiche seulement
     * @type {string}
     */
    this.type = 'ld'
    /**
     * true lorsgue la liste est désactivée
     * @type {boolean}
     */
    this.disabled = false
    /**
     * La liste des choix
     * @type {string[]}
     */
    this.choices = [...choices]
    /**
     * La liste de choix donnée initialement (idem choices si choix0, sinon choices n’a pas le 1er elt)
     * @type {string[]}
     */
    this.givenChoices = [...choices]
    /**
     * true si la liste se déroule vers le haut
     * @type {boolean}
     */
    this.sensHaut = sensHaut === true
    /**
     * Callback éventuelle à appeler avec le choix fait à chaque changement
     */
    this.onChange = typeof onChange === 'function' ? onChange : null
    /**
     * Un décalage éventuel en pixels…
     * @type {number}
     */
    this.decalage = decalage ?? 0
    /**
     * true si la liste doit être alignée sur un input mathquill
     * @type {boolean}
     */
    this.alignmathquill = alignmathquill === true
    this.displayWithMathlive = displayWithMathlive ?? false
    // fin des parametres

    /**
     * L’index du choix sélectionné au clavier
     * @type {number}
     */
    this._kbIndex = -1
    /**
     * Passe à true dès qu’on a fait une sélection (ou dès le départ si le choix initial est sélectionnable)
     * @type {boolean}
     */
    this.changed = false
    /**
     * Décalage éventuel d’index entre le tableau de choix fourni et celui qu’on manipule (0 ou 1 suivant que le premier choix est sélectionnable ou pas)
     * @type {number}
     * @private
     */
    this._offset = choix0 === true ? 0 : 1
    /**
     * Le choix courant
     * @type {string}
     */
    this.reponse = ''

    /**
     * Liste des elts contenant les choix (les <li>)
     * @type {HTMLDivElement[]}
     * @private
     */
    this._elts = []
  }

  /**
   * Crée les éléments dans le DOM (appelé par create) et ajoute les listeners
   * @private
   */
  _init ({ boutonAgauche, centre, conteneur, dansModale, sansFleche, select }) {
    /**
     * Le span qui va contenir la liste (tous les éléments que l’on crée, enfant de conteneur)
     * @type {HTMLSpanElement}
     */
    this._spanContenant = j3pAddElt(conteneur, 'span', '', { className: 'listeDeroulante' })
    // flèche à gauche ?
    const char = this.sensHaut ? '˄' : '˅'
    if (!sansFleche && boutonAgauche) {
      j3pAddElt(this._spanContenant, 'span', char, { className: 'trigger' })
    }
    const spanSelectedProps = { className: 'currentChoice', role: 'listbox', tabIndex: 0 }
    /**
       * Le span de l’élément sélectionné
       * @type {HTMLSpanElement}
       */
    this.spanSelected = j3pAddElt(this._spanContenant, 'span', '', spanSelectedProps)
    // flèche à droite
    if (!sansFleche && !boutonAgauche) {
      j3pAddElt(this._spanContenant, 'span', char, { className: 'trigger' })
    }
    // la liste qui peut être masquée (il doit passer au-dessus du reste, les boutons sont à 50, les modales à 90)
    this.ulContainer = j3pAddElt(this._spanContenant, 'ul', '', { style: { zIndex: dansModale ? '200' : '60' } })

    // si le premier élément de la liste n’est pas sélectionnable, on le sort de la liste
    this.initialChoice = this._offset ? this.choices.shift() : this.choices[0]

    // S’il y a du mathquill dans un des éléments (donc un $ dans la chaîne), il faut que la liste soit visible, sinon mathquill peut faire des bêtises dans ses calculs de dimensionnement
    // Par ex pb avec les radicaux, cf pb avant 2023-05-11 sur http://localhost:8081/?graphe=%5B%5B%221%22%2C%22pyth01%22%2C%5B%7B%22nbrepetitions%22%3A3%2C%22nbchances%22%3A2%2C%22Cote%22%3A%22les%20deux%22%2C%22Enonce%22%3A%22Figure%22%2C%22Inutiles%22%3A%22Non%22%2C%22Brouillon%22%3Atrue%2C%22Description%22%3Afalse%2C%22Egalite%22%3Afalse%2C%22Remplacement%22%3Afalse%2C%22carre%22%3Afalse%2C%22racine%22%3Atrue%2C%22reponse%22%3Atrue%2C%22Entiers%22%3A%22les%20deux%22%2C%22Exacte%22%3A%22les%20deux%22%2C%22Calculatrice%22%3Atrue%2C%22Val_Ap%22%3Atrue%2C%22Sans_Echec%22%3Atrue%2C%22theme%22%3A%22standard%22%7D%5D%5D%5D

    // donc si y’a du mathquill on affiche d’abord, ajoute tous les choix, regarde la largeur puis masque
    const hasMq = this.choices.some(choice => choice.includes('$'))
    if (hasMq) this.show() // le hide sera fait par select|reset à la fin de ce constructeur
    // les choix
    const liProps = { role: 'option' }
    if (centre) liProps.style = { textAlign: 'center' }
    for (const [index, choice] of this.choices.entries()) {
      const li = j3pAddElt(this.ulContainer, 'li', '', liProps)
      // si y’a du mathquill dans choice, faut que ce soit visible, sinon mathquill peut faire des bêtises dans ses calculs de dimensionnement
      if (this.displayWithMathlive) {
        afficheMathliveDans(li, '', choice)
      } else {
        j3pAffiche(li, '', choice)
      }
      li.addEventListener('click', (event) => {
        event.stopPropagation() // faut pas propager à _spanContenant sinon il va rouvrir la liste après select()
        this.select(index, { withoutOffset: true })
      })
      this._elts.push(li)
    }

    // si on a sensHaut, il faut vérifier qu’on ne sort pas de la zone parente (sinon la liste est tronquée,
    // et changer les z-index ne change rien, à cause des stack contexts (https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Positioning/Understanding_z_index/The_stacking_context)
    // cf cet article https://www.freecodecamp.org/news/4-reasons-your-z-index-isnt-working-and-how-to-fix-it-coder-coder-6bc05f103e6c/
    // pour régler ça on pourrait sortir le <ul> de MG et le positionner en absolute par rapport à la page,
    // mais ça pose plein d’autres pbs de positionnement dès que qqchose est redimensionné
    // (une zone de saisie mathquill où on tape une fraction qui change de hauteur par ex)
    // => on laisse le <ul> dans son .listeDeroulante et on se débrouille pour pas déborder du .divZone parent
    // Cf https://forge.apps.education.fr/sesamath/sesaparcours/-/issues/187
    if (this.sensHaut) {
      const zoneParente = getZoneParente(this.ulContainer, true)
      if (zoneParente) {
        const { y: yParent } = zoneParente.getBoundingClientRect()
        const { y: yListe, height } = this.ulContainer.getBoundingClientRect()
        if (height > yListe - yParent) {
          // faut virer sensHaut, ça ajoutera un éventuel scroll mais au moins on pourra avoir la liste complète
          this.sensHaut = false
          // et changer le caractère
          const trigger = this._spanContenant.querySelector('span.trigger')
          if (trigger) trigger.innerText = '˅'
          this._replace()
        }
      }
    }
    this.spanSelected.style.minWidth = this.ulContainer.offsetWidth + 'px'

    // les listeners sur _spanContenant, faut les mettre en propriété pour pouvoir les retirer dans disable()
    // click
    this._clickListener = this.toggle.bind(this)
    this._spanContenant.addEventListener('click', this._clickListener)

    // keydown, pour navigation au clavier
    /**
     * listener de keydown sur spanSelected, pour sélection au clavier
     * @param {KeyboardEvent} event
     */
    this._keydownListener = (event) => {
      const { code, key } = event
      if (code === 'Tab' || key === 'Tab') {
        // on sort du menu au clavier, faut le refermer
        this.hide()
      } else if (code === 'ArrowDown' || key === 'ArrowDown') {
        event.preventDefault()
        if (this._kbIndex < this.choices.length - 1) {
          this._kbIndex++
          if (!this.isVisible()) this.show()
          this._kbSelect()
        }
      } else if (code === 'ArrowUp' || key === 'ArrowUp') {
        event.preventDefault()
        if (this._kbIndex === -1) this._kbIndex = this.choices.length // 1er clic sur arrowUp, on part de la fin
        if (this._kbIndex > 0) {
          this._kbIndex--
          if (!this.isVisible()) this.show()
          this._kbSelect()
        }
      } else if (code === 'Space' || key === 'Space' || code === 'Enter' || key === 'Enter') {
        if (this.isVisible()) {
          this.select(this._kbIndex, { withoutOffset: true })
        } else {
          this.show()
        }
      }
    }
    this.spanSelected.addEventListener('keydown', this._keydownListener)
    // pour refermer le menu si on sort au clavier,
    // on a essayé de refermer le menu au focusout sur _spanContenant (avec un timeout sinon ça ferme avant de déclencher
    // le clic sur un li et on se retrouve à cliquer dessous), mais ça pose plus de pb que ça n’en résoud
    // (le clic sur la flèche referme parfois le menu aussitôt)
    // => on gère le tab sortant dans _keydownListener

    // et un listener pour refermer la liste si on clique ailleurs
    document.body.addEventListener('click', ({ target }) => {
      // si on trouve un .listeDeroulante dans un parent on ne fait rien
      /** @type {null|HTMLElement} */
      let parent = target
      while (parent) {
        if (parent.classList.contains('listeDeroulante')) return
        parent = parent.parentElement
      }
      // sinon on cache
      this.hide()
    })

    // et le choix initial, on passe par select/reset pour éviter de dupliquer du code, mais ils ne doivent pas appeler le listener à l’init
    // (y’a des sections qui nous filent en listener  un truc qui n’est pas encore prêt, faut pas l’appeler tout de suite)
    if (select && select !== -1) this.select(select, { withoutCallback: true })
    // si choix0, on considère ça comme une réponse déjà mise
    else if (this._offset === 0) this.select(0, { withoutCallback: true })
    // sinon reset
    else this.reset({ withoutCallback: true })
  }

  /**
   * met le focus sur l’élément sélectionné
   */
  focus () {
    this.spanSelected.focus()
  }

  /**
   * barre l’item affiché (liste repliée) et désactive la liste
   */
  barre () {
    this.disable({ barre: true })
  }

  /**
   * barre l’item affiché  et désactive la liste si corrige a été appelé avec false avant, ne fait rien sinon
   */
  barreIfKo () {
    this.disable({ barreIfKo: true })
  }

  /**
   * Colore spanSelected en ok|ko|rien
   * @param {boolean|null|undefined} isOk
   */
  corrige (isOk) {
    if (typeof isOk === 'boolean') {
      if (isOk) {
        this.spanSelected.classList.remove('ko')
        this.spanSelected.classList.add('ok')
      } else {
        this.spanSelected.classList.remove('ok')
        this.spanSelected.classList.add('ko')
      }
    } else {
      this.spanSelected.classList.remove('ko')
      this.spanSelected.classList.remove('ok')
    }
  }

  /**
   * positionne le ul par rapport au spanSelected
   * @private
   */
  _replace () {
    const height = this._spanContenant.offsetHeight
    if (this.sensHaut) {
      this.ulContainer.style.bottom = `${height - this.decalage}px`
    } else {
      this.ulContainer.style.top = `${height + this.decalage}px`
    }
  }

  /**
   * Remet la liste dans son état initial
   * @param {Object} [options]
   * @param {boolean} [options.withoutCallback] Passer true pour ne pas appeler la callback
   */
  reset ({ withoutCallback } = {}) {
    this.hide()
    j3pEmpty(this.spanSelected)
    if (this.displayWithMathlive) afficheMathliveDans(this.spanSelected, '', this.initialChoice)
    else j3pAffiche(this.spanSelected, '', this.initialChoice)
    this.spanSelected.style.fontStyle = 'italic'
    this.spanSelected.style.color = 'Grey'
    this._kbIndex = -1
    this.reponse = ''
    this.changed = false
    this._replace() // au cas où la hauteur de spanSelected aurait changé
    if (!withoutCallback && this.onChange) this.onChange(this.initialChoice)
  }

  /**
   * Sélectionne le choix index (dans le tableau fourni initialement)
   * @param {number} index index dans choices
   * @param {Object} [options]
   * @param {boolean} [options.withoutOffset] Passer true si l’index est celui du tableau après avoir éventuellement retiré son 1er élément (à priori usage interne seulement)
   * @param {boolean} [options.withoutCallback] Passer true pour ne pas appeler la callback (seulement à l’init à priori)
   */
  select (index, { withoutOffset, withoutCallback } = {}) {
    if (this.disabled) return
    this.spanSelected.style.fontStyle = ''
    this.spanSelected.style.color = ''
    if (!Number.isInteger(index)) return Error(`index non entier : ${index}`)
    // faut décaler l’index si on a viré le 1er elt à l’init
    const realIndex = withoutOffset ? index : index - this._offset
    if (realIndex < 0 || realIndex >= this.choices.length) {
      if (withoutOffset) return console.error(`index invalide : ${index} non compris entre 0 et ${this.choices.length - 1}`)
      return console.error(`index invalide : ${index} non compris entre ${this._offset} et ${this.choices.length - 1 + this._offset}`)
    }
    j3pEmpty(this.spanSelected)
    const choix = this.choices[realIndex]
    if (this.displayWithMathlive) afficheMathliveDans(this.spanSelected, '', choix)
    else j3pAffiche(this.spanSelected, '', choix)
    this.reponse = choix
    this.changed = true
    if (this.onChange && !withoutCallback) this.onChange(choix)
    this.corrige(null)
    this._kbIndex = index
    for (const [i, li] of this._elts.entries()) {
      if (i === index) li.classList.add('selected')
      else li.classList.remove('selected')
    }
    this._replace() // la hauteur de spanSelected peut changer
    this.focus()
    this.hide()
  }

  /**
   * Marque un élément comme étant sélectionné au clavier (sera ensuite vraiment sélectionné si on appuie ensuite sur entrée)
   */
  _kbSelect () {
    for (const [i, li] of this._elts.entries()) {
      if (this._kbIndex === i) li.classList.add('selected')
      else li.classList.remove('selected')
    }
  }

  hide () {
    this.ulContainer.classList.remove('visible')
  }

  show () {
    if (this.disabled) return
    // il faut d’abord masquer toutes les autres listes qui pourraient être ouverte (pour éviter des chevauchements)
    for (const ul of document.querySelectorAll('.listeDeroulante ul.visible')) {
      ul.classList.remove('visible')
    }
    this.ulContainer.classList.add('visible')
    this.focus() // pour usage au clavier
  }

  toggle () {
    if (this.disabled) return
    if (this.isVisible()) this.hide()
    else this.show()
  }

  isVisible () {
    return this.ulContainer.classList.contains('visible')
  }

  /**
   * Désactive la liste (passe j3pFreezeElt dessus)
   * @param {boolean|Object} [options] (si booléen ce sera traité comme options.barre)
   * @param {boolean} [options.barre] passer true pour appeler j3pBarre dessus (sinon ce sera seulement j3pFreezeElt)
   * @param {boolean} [options.barreIfKo] passer true pour appeler j3pBarre si la sélection a été marquée fausse par corrige() (appelée avant)
   */
  disable (options = {}) {
    if (typeof options === 'boolean') options = { barre: options }
    const { barre, barreIfKo } = options
    if (this.disabled) return
    this.disabled = true
    this._spanContenant.removeEventListener('click', this._clickListener)
    this._spanContenant.removeEventListener('keydown', this._keydownListener)
    this._spanContenant.classList.add('disabled')
    const fleche = this._spanContenant.querySelector('.trigger')
    // avec l’option sansFleche on a pas de flèche
    if (fleche) fleche.style.display = 'none'
    this.spanSelected.classList.remove('active')
    if (barre || (barreIfKo && this.isOk() === false)) barreZone(this.spanSelected)
    j3pFreezeElt(this.spanSelected)
  }

  /**
   * Retourne l’index de la réponse courante (dans le tableau fourni initialement)
   * @return {number}
   */
  getReponseIndex () {
    const index = this.givenChoices.indexOf(this.reponse)
    if (index < 0) return 0 // si on a rien choisi index vaut -1
    return index
  }

  get bon () {
    console.warn('La propriété bon va disparaître, il faut utiliser la méthode isOk()')
    return this.isOk()
  }

  /**
   * Retourne true si la correction est passée et a indiqué que c'était bon, false si c'était faux, undefined si corrige n’a pas été appelé
   * @type {boolean|undefined}
   */
  isOk () {
    if (this.spanSelected.classList.contains('ok')) return true
    if (this.spanSelected.classList.contains('ko')) return false
    // sinon ça retourne undefined
  }

  /**
   * Ajoute la liste dans le dom et retourne l’objet ListeDeroulante qu’elle a créé
   * @name ListeDeroulante
   * @param {HTMLElement|string} conteneur
   * @param {string[]} choices la liste des choix
   * @param {Object} [parametres]  Les paramètres de cette liste
   * @param {function} [parametres.onChange] fonction à exécuter lors de la sélection d’un choix (appelée avec la valeur du choix)
   * @param {boolean} [parametres.alignmathquill] si true, modifie un peu la hauteur pour s’aligner avec des div mathquill de la même ligne
   * @param {boolean} [parametres.boutonAgauche] passer true pour mettre la flèche de déroulement à gauche du select
   * @param {boolean} [parametres.centre] les choix sont centrés (et pas alignés à gauche)
   * @param {string} [parametres.choix] element de choices à sélectionner dès le départ (prioritaire devant select)
   * @param {string|boolean} [parametres.choix0] passer true pour que le premier choix soit sélectionnable
   * @param {boolean} [parametres.sansFleche] passer true pour ne pas mettre la flèche permettant le déroulement
   * @param {number} [parametres.select=0] index de choices à sélectionner dès le départ (ignoré si choix est précisé)
   * @param {boolean} [parametres.sensHaut=false] passer true pour dérouler la liste vers le haut
   * @param {boolean} [parametres.dansModale=false] si true la ça augmente le zindex pour que la liste s’affiche au dessus de la modale
   * @param {boolean} [parametres.decalage=false] true pour un décalage , quand le j3pcontainer est bizarre, faut redécaler à la main la liste
   * @param {boolean} displayWithMathlive true si on affiche les item en LateX via Mathlive
   * @return {ListeDeroulante}
   */
  static create (conteneur, choices, { onChange, alignmathquill, boutonAgauche, centre, choix, choix0, sansFleche, select, sensHaut, dansModale, decalage, displayWithMathlive } = {}) {
    const ld = new ListeDeroulante(choices, { onChange, alignmathquill, boutonAgauche, choix0, sensHaut, decalage, displayWithMathlive })
    if (choix) {
      const index = choices.indexOf(choix)
      if (index === -1) console.error(Error(`Le choix ${choix} n’est pas dans la liste`), choices)
      else select = index
    }
    if (typeof conteneur === 'string') {
      conteneur = j3pElement(conteneur)
      if (!conteneur) throw Error('Impossible de créer la liste déroulante, pas de conteneur')
    }
    ld._init({ boutonAgauche, centre, conteneur, dansModale, sansFleche, select })
    return ld
  }
}

export default ListeDeroulante