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