From 86744d710a304a22154e134b6944ce42994d78f6 Mon Sep 17 00:00:00 2001 From: Philipp Imhof <52650214+PhilippImhof@users.noreply.github.com> Date: Mon, 16 Mar 2026 11:06:10 +0100 Subject: [PATCH 1/4] add setting for tooltip trigger --- amd/build/tooltip.min.js | 2 +- amd/build/tooltip.min.js.map | 2 +- amd/src/tooltip.js | 13 +++++++++---- lang/en/qtype_formulas.php | 7 +++++++ renderer.php | 1 + settings.php | 35 +++++++++++++++++++++++++++-------- 6 files changed, 46 insertions(+), 14 deletions(-) diff --git a/amd/build/tooltip.min.js b/amd/build/tooltip.min.js index e67778f9..08442411 100644 --- a/amd/build/tooltip.min.js +++ b/amd/build/tooltip.min.js @@ -7,6 +7,6 @@ define("qtype_formulas/tooltip",["exports"],(function(_exports){Object.definePro * @author Philipp Imhof * @license https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ -var mouseIsOver=null;const init=()=>{let inputs=document.querySelectorAll("div[class*='formulaspart'] input[type='text'][class*='formulas_']");for(let input of inputs)"true"===input.dataset.qtypeFormulasEnableTooltip&&(input.addEventListener("focus",focused),input.addEventListener("mouseover",mouseOver),input.addEventListener("mouseout",mouseOut),input.addEventListener("blur",lostFocus));document.addEventListener("keydown",dealWithEscape),window.addEventListener("scroll",repositionTooltips),window.addEventListener("resize",repositionTooltips)};_exports.init=init;const dealWithEscape=evt=>{if("Escape"===evt.key){const tooltips=document.querySelectorAll("div.qtype_formulas_tooltip_wrapper");for(let tooltip of tooltips)hideTooltip(tooltip)}},fetchTooltipFor=field=>document.querySelector('[data-qtype-formulas-tooltip-for="'.concat(field.id,'"]')),createTooltipFor=field=>{let wrapper=document.createElement("div");wrapper.classList.add("qtype_formulas_tooltip_wrapper"),wrapper.role="tooltip",wrapper.dataset.qtypeFormulasTooltipFor=field.id;let inner=document.createElement("div");return inner.classList.add("qtype_formulas_tooltip_inner"),inner.textContent=field.title,wrapper.appendChild(inner),document.body.appendChild(wrapper),wrapper},setPersistence=(tooltip,persistent)=>{tooltip.dataset.qtypeFormulasTooltipPersistent=persistent?"true":"false"},repositionTooltips=()=>{const tooltips=document.querySelectorAll("div.qtype_formulas_tooltip_wrapper");for(let tooltip of tooltips){const rect=document.getElementById(tooltip.dataset.qtypeFormulasTooltipFor).getBoundingClientRect(),raise=parseInt(window.getComputedStyle(tooltip.firstChild,"::after").borderTopWidth);tooltip.style.top="".concat(window.scrollY+rect.top-tooltip.offsetHeight-raise,"px"),tooltip.style.left="".concat(window.scrollX+rect.left+rect.width/2,"px")}},showTooltip=tooltip=>{repositionTooltips(),tooltip.classList.add("show")},hideTooltip=tooltip=>{tooltip.classList.remove("show"),setPersistence(tooltip,!1)},lostFocus=evt=>{const field=evt.target;let tooltip=fetchTooltipFor(field);null!==tooltip&&(mouseIsOver===field?setPersistence(tooltip,!1):hideTooltip(tooltip))},mouseOut=evt=>{const field=evt.target;mouseIsOver===field&&(mouseIsOver=null);let tooltip=fetchTooltipFor(field);null!==tooltip&&"true"!==tooltip.dataset.qtypeFormulasTooltipPersistent&&hideTooltip(tooltip)},focused=evt=>{const field=evt.target;let tooltip=fetchTooltipFor(field);null===tooltip&&(tooltip=createTooltipFor(field)),setPersistence(tooltip,!0),showTooltip(tooltip)},mouseOver=evt=>{const field=evt.target;mouseIsOver=field;let tooltip=fetchTooltipFor(field);null===tooltip&&(tooltip=createTooltipFor(field),setPersistence(tooltip,!1)),showTooltip(tooltip)};var _default={init:init};return _exports.default=_default,_exports.default})); +var mouseIsOver=null;const init=()=>{let inputs=document.querySelectorAll("div[class*='formulaspart'] input[type='text'][class*='formulas_']");for(let input of inputs){if("true"!==input.dataset.qtypeFormulasEnableTooltip)continue;let trigger=input.dataset.qtypeFormulasTooltipTrigger;trigger.includes("hover")&&(input.addEventListener("mouseover",mouseOver),input.addEventListener("mouseout",mouseOut)),trigger.includes("focus")&&(input.addEventListener("focus",focused),input.addEventListener("blur",lostFocus))}document.addEventListener("keydown",dealWithEscape),window.addEventListener("scroll",repositionTooltips),window.addEventListener("resize",repositionTooltips)};_exports.init=init;const dealWithEscape=evt=>{if("Escape"===evt.key){const tooltips=document.querySelectorAll("div.qtype_formulas_tooltip_wrapper");for(let tooltip of tooltips)hideTooltip(tooltip)}},fetchTooltipFor=field=>document.querySelector('[data-qtype-formulas-tooltip-for="'.concat(field.id,'"]')),createTooltipFor=field=>{let wrapper=document.createElement("div");wrapper.classList.add("qtype_formulas_tooltip_wrapper"),wrapper.role="tooltip",wrapper.dataset.qtypeFormulasTooltipFor=field.id;let inner=document.createElement("div");return inner.classList.add("qtype_formulas_tooltip_inner"),inner.textContent=field.title,wrapper.appendChild(inner),document.body.appendChild(wrapper),wrapper},setPersistence=(tooltip,persistent)=>{tooltip.dataset.qtypeFormulasTooltipPersistent=persistent?"true":"false"},repositionTooltips=()=>{const tooltips=document.querySelectorAll("div.qtype_formulas_tooltip_wrapper");for(let tooltip of tooltips){const rect=document.getElementById(tooltip.dataset.qtypeFormulasTooltipFor).getBoundingClientRect(),raise=parseInt(window.getComputedStyle(tooltip.firstChild,"::after").borderTopWidth);tooltip.style.top="".concat(window.scrollY+rect.top-tooltip.offsetHeight-raise,"px"),tooltip.style.left="".concat(window.scrollX+rect.left+rect.width/2,"px")}},showTooltip=tooltip=>{repositionTooltips(),tooltip.classList.add("show")},hideTooltip=tooltip=>{tooltip.classList.remove("show"),setPersistence(tooltip,!1)},lostFocus=evt=>{const field=evt.target;let tooltip=fetchTooltipFor(field);null!==tooltip&&(mouseIsOver===field?setPersistence(tooltip,!1):hideTooltip(tooltip))},mouseOut=evt=>{const field=evt.target;mouseIsOver===field&&(mouseIsOver=null);let tooltip=fetchTooltipFor(field);null!==tooltip&&"true"!==tooltip.dataset.qtypeFormulasTooltipPersistent&&hideTooltip(tooltip)},focused=evt=>{const field=evt.target;let tooltip=fetchTooltipFor(field);null===tooltip&&(tooltip=createTooltipFor(field)),setPersistence(tooltip,!0),showTooltip(tooltip)},mouseOver=evt=>{const field=evt.target;mouseIsOver=field;let tooltip=fetchTooltipFor(field);null===tooltip&&(tooltip=createTooltipFor(field),setPersistence(tooltip,!1)),showTooltip(tooltip)};var _default={init:init};return _exports.default=_default,_exports.default})); //# sourceMappingURL=tooltip.min.js.map \ No newline at end of file diff --git a/amd/build/tooltip.min.js.map b/amd/build/tooltip.min.js.map index 2999e15d..154d6795 100644 --- a/amd/build/tooltip.min.js.map +++ b/amd/build/tooltip.min.js.map @@ -1 +1 @@ -{"version":3,"file":"tooltip.min.js","sources":["../src/tooltip.js"],"sourcesContent":["// This file is part of Moodle - https://moodle.org/\n//\n// Moodle is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n//\n// Moodle is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n// GNU General Public License for more details.\n//\n// You should have received a copy of the GNU General Public License\n// along with Moodle. If not, see .\n\n/**\n * Tooltip implementation for the Formulas question plugin.\n *\n * @module qtype_formulas/tooltip\n * @copyright 2026 Philipp Imhof\n * @author Philipp Imhof\n * @license https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\n\nvar mouseIsOver = null;\n\n/**\n * Initialisation, i. e. attaching event handlers to the input fields.\n */\nexport const init = () => {\n let inputs = document.querySelectorAll(\"div[class*='formulaspart'] input[type='text'][class*='formulas_']\");\n for (let input of inputs) {\n if (input.dataset.qtypeFormulasEnableTooltip !== 'true') {\n continue;\n }\n input.addEventListener('focus', focused);\n input.addEventListener('mouseover', mouseOver);\n input.addEventListener('mouseout', mouseOut);\n input.addEventListener('blur', lostFocus);\n }\n\n // When the user presses the Escape key anywhere, the tooltips should be removed in order to follow\n // accessibility standards.\n document.addEventListener('keydown', dealWithEscape);\n\n // Add event listener for scrolling and resizing, because our tooltips might have to be shifted.\n window.addEventListener('scroll', repositionTooltips);\n window.addEventListener('resize', repositionTooltips);\n};\n\n/**\n * Event handler for the Escape key: remove all our tooltips.\n *\n * @param {Event} evt\n */\nconst dealWithEscape = (evt) => {\n if (evt.key === 'Escape') {\n const tooltips = document.querySelectorAll('div.qtype_formulas_tooltip_wrapper');\n for (let tooltip of tooltips) {\n hideTooltip(tooltip);\n }\n }\n};\n\n/**\n * Fetch the tooltip for a given input field, if it exists. Return null otherwise.\n *\n * @param {Element} field the input field\n * @returns {Element|null}\n */\nconst fetchTooltipFor = (field) => {\n return document.querySelector(`[data-qtype-formulas-tooltip-for=\"${field.id}\"]`);\n};\n\n/**\n * Create and return a tooltip for the given input field, without activating (showing) it.\n *\n * @param {Element} field the input field\n * @returns Element\n */\nconst createTooltipFor = (field) => {\n let wrapper = document.createElement('div');\n wrapper.classList.add('qtype_formulas_tooltip_wrapper');\n wrapper.role = 'tooltip';\n wrapper.dataset.qtypeFormulasTooltipFor = field.id;\n\n let inner = document.createElement('div');\n inner.classList.add('qtype_formulas_tooltip_inner');\n inner.textContent = field.title;\n wrapper.appendChild(inner);\n document.body.appendChild(wrapper);\n\n return wrapper;\n};\n\n/**\n * Set whether a given tooltip should be persistent, i. e. remain visible when the mouse goes away.\n * This is generally the desired behaviour when a field is focused.\n *\n * @param {Element} tooltip the tooltip to make persistent\n * @param {boolean} persistent whether it should be persistent (true) or not\n */\nconst setPersistence = (tooltip, persistent) => {\n tooltip.dataset.qtypeFormulasTooltipPersistent = (persistent ? 'true' : 'false');\n};\n\n/**\n * Make sure the tooltips are correctly positioned above the input field, taking into account the\n * scroll position.\n *\n * @returns void\n */\nconst repositionTooltips = () => {\n const tooltips = document.querySelectorAll('div.qtype_formulas_tooltip_wrapper');\n for (let tooltip of tooltips) {\n const input = document.getElementById(tooltip.dataset.qtypeFormulasTooltipFor);\n const rect = input.getBoundingClientRect();\n const raise = parseInt(window.getComputedStyle(tooltip.firstChild, '::after').borderTopWidth);\n\n tooltip.style.top = `${window.scrollY + rect.top - tooltip.offsetHeight - raise}px`;\n tooltip.style.left = `${window.scrollX + rect.left + rect.width / 2}px`;\n }\n};\n\n/**\n * Show a given tooltip.\n *\n * @param {Element} tooltip the tooltip to be shown\n */\nconst showTooltip = (tooltip) => {\n repositionTooltips();\n tooltip.classList.add('show');\n};\n\n/**\n * Hide a given tooltip.\n *\n * @param {Element} tooltip the tooltip to be hidden\n */\nconst hideTooltip = (tooltip) => {\n tooltip.classList.remove('show');\n setPersistence(tooltip, false);\n};\n\n/**\n * Handle the blur event for our input fields, i. e. hide the tooltip or mark it as non-persistent, if the\n * mouse is still over the field.\n *\n * @param {Event} evt\n * @returns void\n */\nconst lostFocus = (evt) => {\n const field = evt.target;\n\n // Fetch the tooltip. If it does not exist (which should not happen), we just leave.\n let tooltip = fetchTooltipFor(field);\n if (tooltip === null) {\n return;\n }\n\n // If the mouse is currently over our input field, we just mark the tooltip as non-persistent, in order for it\n // to be hidden when the mouse moves out. Otherwise, we hide the tooltip.\n if (mouseIsOver === field) {\n setPersistence(tooltip, false);\n } else {\n hideTooltip(tooltip);\n }\n};\n\n/**\n * Handle the mouseout event.\n *\n * @param {Event} evt\n * @returns void\n */\nconst mouseOut = (evt) => {\n const field = evt.target;\n\n // If the mouse is currently registered as being over the field we have just left, clear the\n // reference. If for some strange reason (e. g. delays) the mouse has already been registered\n // as hovering over another field, do not touch the reference.\n if (mouseIsOver === field) {\n mouseIsOver = null;\n }\n\n // If there is no tooltip, we leave.\n let tooltip = fetchTooltipFor(field);\n if (tooltip === null) {\n return;\n }\n\n // If the tooltip is meant to be persistent, we leave. Note that it is not enough to check whether\n // the field has focus, because when the user has pressed the Escape key while a field was focused,\n // then we want to hide it on the mouseout event despite the focus.\n if (tooltip.dataset.qtypeFormulasTooltipPersistent === 'true') {\n return;\n }\n\n hideTooltip(tooltip);\n};\n\n/**\n * Event handler when input field receives focus.\n *\n * @param {Event} evt event with details\n * @returns void\n */\nconst focused = (evt) => {\n const field = evt.target;\n\n // Fetch the tooltip and, if it does not exist, create it.\n let tooltip = fetchTooltipFor(field);\n if (tooltip === null) {\n tooltip = createTooltipFor(field);\n }\n // When a field gains or regains focus, the tooltip should become persistent, i. e. not be hidden when\n // the mouse moves away.\n setPersistence(tooltip, true);\n\n showTooltip(tooltip);\n};\n\n/**\n * Handle the mouseover event.\n *\n * @param {Event} evt\n */\nconst mouseOver = (evt) => {\n const field = evt.target;\n\n mouseIsOver = field;\n\n // Fetch the tooltip and, if it does not exist, create it. Tooltips created when the mouse hovers over a\n // field should be temporary only. However, persistence should not be changed for existing tooltips.\n let tooltip = fetchTooltipFor(field);\n if (tooltip === null) {\n tooltip = createTooltipFor(field);\n setPersistence(tooltip, false);\n }\n\n showTooltip(tooltip);\n};\n\nexport default {init};\n"],"names":["mouseIsOver","init","inputs","document","querySelectorAll","input","dataset","qtypeFormulasEnableTooltip","addEventListener","focused","mouseOver","mouseOut","lostFocus","dealWithEscape","window","repositionTooltips","evt","key","tooltips","tooltip","hideTooltip","fetchTooltipFor","field","querySelector","id","createTooltipFor","wrapper","createElement","classList","add","role","qtypeFormulasTooltipFor","inner","textContent","title","appendChild","body","setPersistence","persistent","qtypeFormulasTooltipPersistent","rect","getElementById","getBoundingClientRect","raise","parseInt","getComputedStyle","firstChild","borderTopWidth","style","top","scrollY","offsetHeight","left","scrollX","width","showTooltip","remove","target"],"mappings":";;;;;;;;;IAwBIA,YAAc,WAKLC,KAAO,SACZC,OAASC,SAASC,iBAAiB,yEAClC,IAAIC,SAASH,OACmC,SAA7CG,MAAMC,QAAQC,6BAGlBF,MAAMG,iBAAiB,QAASC,SAChCJ,MAAMG,iBAAiB,YAAaE,WACpCL,MAAMG,iBAAiB,WAAYG,UACnCN,MAAMG,iBAAiB,OAAQI,YAKnCT,SAASK,iBAAiB,UAAWK,gBAGrCC,OAAON,iBAAiB,SAAUO,oBAClCD,OAAON,iBAAiB,SAAUO,8CAQhCF,eAAkBG,SACJ,WAAZA,IAAIC,IAAkB,OAChBC,SAAWf,SAASC,iBAAiB,0CACtC,IAAIe,WAAWD,SAChBE,YAAYD,WAWlBE,gBAAmBC,OACdnB,SAASoB,0DAAmDD,MAAME,UASvEC,iBAAoBH,YAClBI,QAAUvB,SAASwB,cAAc,OACrCD,QAAQE,UAAUC,IAAI,kCACtBH,QAAQI,KAAO,UACfJ,QAAQpB,QAAQyB,wBAA0BT,MAAME,OAE5CQ,MAAQ7B,SAASwB,cAAc,cACnCK,MAAMJ,UAAUC,IAAI,gCACpBG,MAAMC,YAAcX,MAAMY,MAC1BR,QAAQS,YAAYH,OACpB7B,SAASiC,KAAKD,YAAYT,SAEnBA,SAULW,eAAiB,CAAClB,QAASmB,cAC7BnB,QAAQb,QAAQiC,+BAAkCD,WAAa,OAAS,SAStEvB,mBAAqB,WACjBG,SAAWf,SAASC,iBAAiB,0CACtC,IAAIe,WAAWD,SAAU,OAEpBsB,KADQrC,SAASsC,eAAetB,QAAQb,QAAQyB,yBACnCW,wBACbC,MAAQC,SAAS9B,OAAO+B,iBAAiB1B,QAAQ2B,WAAY,WAAWC,gBAE9E5B,QAAQ6B,MAAMC,cAASnC,OAAOoC,QAAUV,KAAKS,IAAM9B,QAAQgC,aAAeR,YAC1ExB,QAAQ6B,MAAMI,eAAUtC,OAAOuC,QAAUb,KAAKY,KAAOZ,KAAKc,MAAQ,UASpEC,YAAepC,UACjBJ,qBACAI,QAAQS,UAAUC,IAAI,SAQpBT,YAAeD,UACjBA,QAAQS,UAAU4B,OAAO,QACzBnB,eAAelB,SAAS,IAUtBP,UAAaI,YACTM,MAAQN,IAAIyC,WAGdtC,QAAUE,gBAAgBC,OACd,OAAZH,UAMAnB,cAAgBsB,MAChBe,eAAelB,SAAS,GAExBC,YAAYD,WAUdR,SAAYK,YACRM,MAAQN,IAAIyC,OAKdzD,cAAgBsB,QAChBtB,YAAc,UAIdmB,QAAUE,gBAAgBC,OACd,OAAZH,SAOmD,SAAnDA,QAAQb,QAAQiC,gCAIpBnB,YAAYD,UASVV,QAAWO,YACPM,MAAQN,IAAIyC,WAGdtC,QAAUE,gBAAgBC,OACd,OAAZH,UACAA,QAAUM,iBAAiBH,QAI/Be,eAAelB,SAAS,GAExBoC,YAAYpC,UAQVT,UAAaM,YACTM,MAAQN,IAAIyC,OAElBzD,YAAcsB,UAIVH,QAAUE,gBAAgBC,OACd,OAAZH,UACAA,QAAUM,iBAAiBH,OAC3Be,eAAelB,SAAS,IAG5BoC,YAAYpC,uBAGD,CAAClB,KAAAA"} \ No newline at end of file +{"version":3,"file":"tooltip.min.js","sources":["../src/tooltip.js"],"sourcesContent":["// This file is part of Moodle - https://moodle.org/\n//\n// Moodle is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n//\n// Moodle is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n// GNU General Public License for more details.\n//\n// You should have received a copy of the GNU General Public License\n// along with Moodle. If not, see .\n\n/**\n * Tooltip implementation for the Formulas question plugin.\n *\n * @module qtype_formulas/tooltip\n * @copyright 2026 Philipp Imhof\n * @author Philipp Imhof\n * @license https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\n\nvar mouseIsOver = null;\n\n/**\n * Initialisation, i. e. attaching event handlers to the input fields.\n */\nexport const init = () => {\n let inputs = document.querySelectorAll(\"div[class*='formulaspart'] input[type='text'][class*='formulas_']\");\n for (let input of inputs) {\n if (input.dataset.qtypeFormulasEnableTooltip !== 'true') {\n continue;\n }\n let trigger = input.dataset.qtypeFormulasTooltipTrigger;\n if (trigger.includes('hover')) {\n input.addEventListener('mouseover', mouseOver);\n input.addEventListener('mouseout', mouseOut);\n }\n if (trigger.includes('focus')) {\n input.addEventListener('focus', focused);\n input.addEventListener('blur', lostFocus);\n }\n }\n\n // When the user presses the Escape key anywhere, the tooltips should be removed in order to follow\n // accessibility standards.\n document.addEventListener('keydown', dealWithEscape);\n\n // Add event listener for scrolling and resizing, because our tooltips might have to be shifted.\n window.addEventListener('scroll', repositionTooltips);\n window.addEventListener('resize', repositionTooltips);\n};\n\n/**\n * Event handler for the Escape key: remove all our tooltips.\n *\n * @param {Event} evt\n */\nconst dealWithEscape = (evt) => {\n if (evt.key === 'Escape') {\n const tooltips = document.querySelectorAll('div.qtype_formulas_tooltip_wrapper');\n for (let tooltip of tooltips) {\n hideTooltip(tooltip);\n }\n }\n};\n\n/**\n * Fetch the tooltip for a given input field, if it exists. Return null otherwise.\n *\n * @param {Element} field the input field\n * @returns {Element|null}\n */\nconst fetchTooltipFor = (field) => {\n return document.querySelector(`[data-qtype-formulas-tooltip-for=\"${field.id}\"]`);\n};\n\n/**\n * Create and return a tooltip for the given input field, without activating (showing) it.\n *\n * @param {Element} field the input field\n * @returns Element\n */\nconst createTooltipFor = (field) => {\n let wrapper = document.createElement('div');\n wrapper.classList.add('qtype_formulas_tooltip_wrapper');\n wrapper.role = 'tooltip';\n wrapper.dataset.qtypeFormulasTooltipFor = field.id;\n\n let inner = document.createElement('div');\n inner.classList.add('qtype_formulas_tooltip_inner');\n inner.textContent = field.title;\n wrapper.appendChild(inner);\n document.body.appendChild(wrapper);\n\n return wrapper;\n};\n\n/**\n * Set whether a given tooltip should be persistent, i. e. remain visible when the mouse goes away.\n * This is generally the desired behaviour when a field is focused.\n *\n * @param {Element} tooltip the tooltip to make persistent\n * @param {boolean} persistent whether it should be persistent (true) or not\n */\nconst setPersistence = (tooltip, persistent) => {\n tooltip.dataset.qtypeFormulasTooltipPersistent = (persistent ? 'true' : 'false');\n};\n\n/**\n * Make sure the tooltips are correctly positioned above the input field, taking into account the\n * scroll position.\n *\n * @returns void\n */\nconst repositionTooltips = () => {\n const tooltips = document.querySelectorAll('div.qtype_formulas_tooltip_wrapper');\n for (let tooltip of tooltips) {\n const input = document.getElementById(tooltip.dataset.qtypeFormulasTooltipFor);\n const rect = input.getBoundingClientRect();\n const raise = parseInt(window.getComputedStyle(tooltip.firstChild, '::after').borderTopWidth);\n\n tooltip.style.top = `${window.scrollY + rect.top - tooltip.offsetHeight - raise}px`;\n tooltip.style.left = `${window.scrollX + rect.left + rect.width / 2}px`;\n }\n};\n\n/**\n * Show a given tooltip.\n *\n * @param {Element} tooltip the tooltip to be shown\n */\nconst showTooltip = (tooltip) => {\n repositionTooltips();\n tooltip.classList.add('show');\n};\n\n/**\n * Hide a given tooltip.\n *\n * @param {Element} tooltip the tooltip to be hidden\n */\nconst hideTooltip = (tooltip) => {\n tooltip.classList.remove('show');\n setPersistence(tooltip, false);\n};\n\n/**\n * Handle the blur event for our input fields, i. e. hide the tooltip or mark it as non-persistent, if the\n * mouse is still over the field.\n *\n * @param {Event} evt\n * @returns void\n */\nconst lostFocus = (evt) => {\n const field = evt.target;\n\n // Fetch the tooltip. If it does not exist (which should not happen), we just leave.\n let tooltip = fetchTooltipFor(field);\n if (tooltip === null) {\n return;\n }\n\n // If the mouse is currently over our input field, we just mark the tooltip as non-persistent, in order for it\n // to be hidden when the mouse moves out. Otherwise, we hide the tooltip.\n if (mouseIsOver === field) {\n setPersistence(tooltip, false);\n } else {\n hideTooltip(tooltip);\n }\n};\n\n/**\n * Handle the mouseout event.\n *\n * @param {Event} evt\n * @returns void\n */\nconst mouseOut = (evt) => {\n const field = evt.target;\n\n // If the mouse is currently registered as being over the field we have just left, clear the\n // reference. If for some strange reason (e. g. delays) the mouse has already been registered\n // as hovering over another field, do not touch the reference.\n if (mouseIsOver === field) {\n mouseIsOver = null;\n }\n\n // If there is no tooltip, we leave.\n let tooltip = fetchTooltipFor(field);\n if (tooltip === null) {\n return;\n }\n\n // If the tooltip is meant to be persistent, we leave. Note that it is not enough to check whether\n // the field has focus, because when the user has pressed the Escape key while a field was focused,\n // then we want to hide it on the mouseout event despite the focus.\n if (tooltip.dataset.qtypeFormulasTooltipPersistent === 'true') {\n return;\n }\n\n hideTooltip(tooltip);\n};\n\n/**\n * Event handler when input field receives focus.\n *\n * @param {Event} evt event with details\n * @returns void\n */\nconst focused = (evt) => {\n const field = evt.target;\n\n // Fetch the tooltip and, if it does not exist, create it.\n let tooltip = fetchTooltipFor(field);\n if (tooltip === null) {\n tooltip = createTooltipFor(field);\n }\n // When a field gains or regains focus, the tooltip should become persistent, i. e. not be hidden when\n // the mouse moves away.\n setPersistence(tooltip, true);\n\n showTooltip(tooltip);\n};\n\n/**\n * Handle the mouseover event.\n *\n * @param {Event} evt\n */\nconst mouseOver = (evt) => {\n const field = evt.target;\n\n mouseIsOver = field;\n\n // Fetch the tooltip and, if it does not exist, create it. Tooltips created when the mouse hovers over a\n // field should be temporary only. However, persistence should not be changed for existing tooltips.\n let tooltip = fetchTooltipFor(field);\n if (tooltip === null) {\n tooltip = createTooltipFor(field);\n setPersistence(tooltip, false);\n }\n\n showTooltip(tooltip);\n};\n\nexport default {init};\n"],"names":["mouseIsOver","init","inputs","document","querySelectorAll","input","dataset","qtypeFormulasEnableTooltip","trigger","qtypeFormulasTooltipTrigger","includes","addEventListener","mouseOver","mouseOut","focused","lostFocus","dealWithEscape","window","repositionTooltips","evt","key","tooltips","tooltip","hideTooltip","fetchTooltipFor","field","querySelector","id","createTooltipFor","wrapper","createElement","classList","add","role","qtypeFormulasTooltipFor","inner","textContent","title","appendChild","body","setPersistence","persistent","qtypeFormulasTooltipPersistent","rect","getElementById","getBoundingClientRect","raise","parseInt","getComputedStyle","firstChild","borderTopWidth","style","top","scrollY","offsetHeight","left","scrollX","width","showTooltip","remove","target"],"mappings":";;;;;;;;;IAwBIA,YAAc,WAKLC,KAAO,SACZC,OAASC,SAASC,iBAAiB,yEAClC,IAAIC,SAASH,OAAQ,IAC2B,SAA7CG,MAAMC,QAAQC,wCAGdC,QAAUH,MAAMC,QAAQG,4BACxBD,QAAQE,SAAS,WACjBL,MAAMM,iBAAiB,YAAaC,WACpCP,MAAMM,iBAAiB,WAAYE,WAEnCL,QAAQE,SAAS,WACjBL,MAAMM,iBAAiB,QAASG,SAChCT,MAAMM,iBAAiB,OAAQI,YAMvCZ,SAASQ,iBAAiB,UAAWK,gBAGrCC,OAAON,iBAAiB,SAAUO,oBAClCD,OAAON,iBAAiB,SAAUO,8CAQhCF,eAAkBG,SACJ,WAAZA,IAAIC,IAAkB,OAChBC,SAAWlB,SAASC,iBAAiB,0CACtC,IAAIkB,WAAWD,SAChBE,YAAYD,WAWlBE,gBAAmBC,OACdtB,SAASuB,0DAAmDD,MAAME,UASvEC,iBAAoBH,YAClBI,QAAU1B,SAAS2B,cAAc,OACrCD,QAAQE,UAAUC,IAAI,kCACtBH,QAAQI,KAAO,UACfJ,QAAQvB,QAAQ4B,wBAA0BT,MAAME,OAE5CQ,MAAQhC,SAAS2B,cAAc,cACnCK,MAAMJ,UAAUC,IAAI,gCACpBG,MAAMC,YAAcX,MAAMY,MAC1BR,QAAQS,YAAYH,OACpBhC,SAASoC,KAAKD,YAAYT,SAEnBA,SAULW,eAAiB,CAAClB,QAASmB,cAC7BnB,QAAQhB,QAAQoC,+BAAkCD,WAAa,OAAS,SAStEvB,mBAAqB,WACjBG,SAAWlB,SAASC,iBAAiB,0CACtC,IAAIkB,WAAWD,SAAU,OAEpBsB,KADQxC,SAASyC,eAAetB,QAAQhB,QAAQ4B,yBACnCW,wBACbC,MAAQC,SAAS9B,OAAO+B,iBAAiB1B,QAAQ2B,WAAY,WAAWC,gBAE9E5B,QAAQ6B,MAAMC,cAASnC,OAAOoC,QAAUV,KAAKS,IAAM9B,QAAQgC,aAAeR,YAC1ExB,QAAQ6B,MAAMI,eAAUtC,OAAOuC,QAAUb,KAAKY,KAAOZ,KAAKc,MAAQ,UASpEC,YAAepC,UACjBJ,qBACAI,QAAQS,UAAUC,IAAI,SAQpBT,YAAeD,UACjBA,QAAQS,UAAU4B,OAAO,QACzBnB,eAAelB,SAAS,IAUtBP,UAAaI,YACTM,MAAQN,IAAIyC,WAGdtC,QAAUE,gBAAgBC,OACd,OAAZH,UAMAtB,cAAgByB,MAChBe,eAAelB,SAAS,GAExBC,YAAYD,WAUdT,SAAYM,YACRM,MAAQN,IAAIyC,OAKd5D,cAAgByB,QAChBzB,YAAc,UAIdsB,QAAUE,gBAAgBC,OACd,OAAZH,SAOmD,SAAnDA,QAAQhB,QAAQoC,gCAIpBnB,YAAYD,UASVR,QAAWK,YACPM,MAAQN,IAAIyC,WAGdtC,QAAUE,gBAAgBC,OACd,OAAZH,UACAA,QAAUM,iBAAiBH,QAI/Be,eAAelB,SAAS,GAExBoC,YAAYpC,UAQVV,UAAaO,YACTM,MAAQN,IAAIyC,OAElB5D,YAAcyB,UAIVH,QAAUE,gBAAgBC,OACd,OAAZH,UACAA,QAAUM,iBAAiBH,OAC3Be,eAAelB,SAAS,IAG5BoC,YAAYpC,uBAGD,CAACrB,KAAAA"} \ No newline at end of file diff --git a/amd/src/tooltip.js b/amd/src/tooltip.js index 3678d728..94ae2d89 100644 --- a/amd/src/tooltip.js +++ b/amd/src/tooltip.js @@ -33,10 +33,15 @@ export const init = () => { if (input.dataset.qtypeFormulasEnableTooltip !== 'true') { continue; } - input.addEventListener('focus', focused); - input.addEventListener('mouseover', mouseOver); - input.addEventListener('mouseout', mouseOut); - input.addEventListener('blur', lostFocus); + let trigger = input.dataset.qtypeFormulasTooltipTrigger; + if (trigger.includes('hover')) { + input.addEventListener('mouseover', mouseOver); + input.addEventListener('mouseout', mouseOut); + } + if (trigger.includes('focus')) { + input.addEventListener('focus', focused); + input.addEventListener('blur', lostFocus); + } } // When the user presses the Escape key anywhere, the tooltips should be removed in order to follow diff --git a/lang/en/qtype_formulas.php b/lang/en/qtype_formulas.php index be0bce67..8f388f51 100644 --- a/lang/en/qtype_formulas.php +++ b/lang/en/qtype_formulas.php @@ -340,6 +340,8 @@ $string['settinglenientimport_desc'] = 'When importing a question, do not check whether the provided model answers would receive full marks.
Note: You should only activate this setting temporarily.'; $string['settings_heading_general'] = 'General preferences'; $string['settings_heading_general_desc'] = ''; +$string['settings_heading_tooltip'] = 'Tooltips'; +$string['settings_heading_tooltip_desc'] = ''; $string['settings_heading_width'] = 'Default widths'; $string['settings_heading_width_desc'] = 'Default width of input fields for the various answer types. For fields that are left empty, the settings from the plugin\'s style file will be used. Please use this settings carefully. Making the fields too small can make it difficult for your students to type their answer. Note that the exclamation mark icon shown for invalid answers takes up approximately 12 pixels.'; $string['settingshownumbertooltip'] = 'Show "Number" tooltip'; @@ -354,6 +356,11 @@ All missing fields are automatically appended at the end of the part\'s text. A special case is that if {_0} and {_u} are specified consecutively with no space, and there is only one answer field and unit, i. e. {_0}{_u}, they will be combined into a single long input answer field for both answer and unit.'; +$string['tooltiptrigger'] = 'Trigger'; +$string['tooltiptrigger_desc'] = 'On what occasions the tooltips should be shown.'; +$string['triggerboth'] = 'Focus or mouse hover'; +$string['triggerdisabled'] = 'No tooltips'; +$string['triggerhover'] = 'Mouse hover only'; $string['uniquecorrectansweris'] = 'The correct answer is: {$a}'; $string['unit'] = 'Unit'; $string['unitpenalty'] = 'Deduction for wrong unit (0-1)*'; diff --git a/renderer.php b/renderer.php index b3ebaac6..81ca7d84 100644 --- a/renderer.php +++ b/renderer.php @@ -657,6 +657,7 @@ protected function create_input_box( $shownumbertooltip = get_config('qtype_formulas', 'shownumbertooltip'); $inputattributes += [ 'data-qtype-formulas-enable-tooltip' => (!$isnumber || $shownumbertooltip ? 'true' : 'false'), + 'data-qtype-formulas-tooltip-trigger' => get_config('qtype_formulas', 'tooltiptrigger'), ]; if ($displayoptions->readonly) { diff --git a/settings.php b/settings.php index 0462560a..1863e1e8 100644 --- a/settings.php +++ b/settings.php @@ -39,14 +39,6 @@ 0 )); - // Whether the tooltip for answer type "Number" should be shown. - $settings->add(new admin_setting_configcheckbox( - 'qtype_formulas/shownumbertooltip', - new lang_string('settingshownumbertooltip', 'qtype_formulas'), - new lang_string('settingshownumbertooltip_desc', 'qtype_formulas'), - 1 - )); - // Default delay for the on-the-fly validation's debounce timer. $settings->add(new admin_setting_configselect( 'qtype_formulas/debouncedelay', @@ -106,6 +98,33 @@ 4 )); + $settings->add(new admin_setting_heading( + 'qtype_formulas/tooltip', + new lang_string('settings_heading_tooltip', 'qtype_formulas'), + new lang_string('settings_heading_tooltip_desc', 'qtype_formulas'), + )); + + // Tooltip trigger. + $settings->add(new admin_setting_configselect( + 'qtype_formulas/tooltiptrigger', + new lang_string('tooltiptrigger', 'qtype_formulas'), + new lang_string('tooltiptrigger_desc', 'qtype_formulas'), + 0, + [ + 'hover,focus' => new lang_string('triggerboth', 'qtype_formulas'), + 'hover' => new lang_string('triggerhover', 'qtype_formulas'), + '' => new lang_string('triggerdisabled', 'qtype_formulas'), + ] + )); + + // Whether the tooltip for answer type "Number" should be shown. + $settings->add(new admin_setting_configcheckbox( + 'qtype_formulas/shownumbertooltip', + new lang_string('settingshownumbertooltip', 'qtype_formulas'), + new lang_string('settingshownumbertooltip_desc', 'qtype_formulas'), + 1 + )); + $settings->add(new admin_setting_heading( 'qtype_formulas/defaultwidths', new lang_string('settings_heading_width', 'qtype_formulas'), From e6d510e77e231da3cc3a72f9bb4a6d43e837f30c Mon Sep 17 00:00:00 2001 From: Philipp Imhof <52650214+PhilippImhof@users.noreply.github.com> Date: Mon, 16 Mar 2026 11:57:08 +0100 Subject: [PATCH 2/4] update tests, add scenario --- tests/behat/tooltips.feature | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/tests/behat/tooltips.feature b/tests/behat/tooltips.feature index 57589c67..313e3cc6 100644 --- a/tests/behat/tooltips.feature +++ b/tests/behat/tooltips.feature @@ -44,6 +44,8 @@ Feature: Display of tooltips And quiz "Quiz 5" contains the following questions: | question | page | | twoandtwo | 1 | + And the following config values are set as admin: + | tooltiptrigger | hover,focus | qtype_formulas | And I log in as "student" And I am on "Course 1" course homepage @@ -139,3 +141,13 @@ Feature: Display of tooltips And I press the escape key Then I should not see "Number and unit" And "" "qtype_formulas > tooltip" should not be visible + + Scenario: Tooltip is not shown on focus, if set to hover only + When the following config values are set as admin: + | tooltiptrigger | hover | qtype_formulas | + And I follow "Quiz 3" + And I press "Attempt quiz" + And I set the field "Answer" to "5 m/s" + Then I should not see "Number and unit" + And "" "qtype_formulas > tooltip" should not be visible + From 69f3be8cd5742a39ff0f8cafb016b224754964e8 Mon Sep 17 00:00:00 2001 From: Philipp Imhof <52650214+PhilippImhof@users.noreply.github.com> Date: Mon, 16 Mar 2026 13:20:11 +0100 Subject: [PATCH 3/4] add unit test + one Behat feature --- settings.php | 2 +- tests/behat/tooltips.feature | 9 +++++++++ tests/renderer_test.php | 27 ++++++++++++++++++++++++++- 3 files changed, 36 insertions(+), 2 deletions(-) diff --git a/settings.php b/settings.php index 1863e1e8..c80245cd 100644 --- a/settings.php +++ b/settings.php @@ -109,7 +109,7 @@ 'qtype_formulas/tooltiptrigger', new lang_string('tooltiptrigger', 'qtype_formulas'), new lang_string('tooltiptrigger_desc', 'qtype_formulas'), - 0, + 'hover', [ 'hover,focus' => new lang_string('triggerboth', 'qtype_formulas'), 'hover' => new lang_string('triggerhover', 'qtype_formulas'), diff --git a/tests/behat/tooltips.feature b/tests/behat/tooltips.feature index 313e3cc6..b0dc2cb3 100644 --- a/tests/behat/tooltips.feature +++ b/tests/behat/tooltips.feature @@ -151,3 +151,12 @@ Feature: Display of tooltips Then I should not see "Number and unit" And "" "qtype_formulas > tooltip" should not be visible + Scenario: Tooltip is not shown on focus, if disabled + When the following config values are set as admin: + | tooltiptrigger | | qtype_formulas | + And I follow "Quiz 3" + And I press "Attempt quiz" + And I set the field "Answer" to "5 m/s" + Then I should not see "Number and unit" + And "" "qtype_formulas > tooltip" should not be visible + diff --git a/tests/renderer_test.php b/tests/renderer_test.php index 5a7e27b1..5434e0bb 100644 --- a/tests/renderer_test.php +++ b/tests/renderer_test.php @@ -813,7 +813,7 @@ public function test_render_mce_accessibility_labels(): void { ); } - public function test_textbox_tooltip_title(): void { + public function test_textbox_tooltip_enable(): void { // Create a simple test question. First, 'shownumbertooltip' is enabled. Then we disable the option. // This should only have an effect for the number type. $q = $this->get_test_formulas_question('testsinglenum'); @@ -864,6 +864,31 @@ public function test_textbox_tooltip_title(): void { ); } + public function test_textbox_tooltip_trigger(): void { + // Create a simple test question. Check the data-qtype-formulas-tooltip-trigger attribute is set + // correctly. The default value is 'hover'. + $q = $this->get_test_formulas_question('testsinglenum'); + $this->start_attempt_at_question($q, 'immediatefeedback', 1); + $this->check_current_output( + new \question_contains_tag_with_attribute('input', 'data-qtype-formulas-tooltip-trigger', 'hover'), + ); + set_config('tooltiptrigger', 'hover,focus', 'qtype_formulas'); + $this->start_attempt_at_question($q, 'immediatefeedback', 1); + $this->check_current_output( + new \question_contains_tag_with_attribute('input', 'data-qtype-formulas-tooltip-trigger', 'hover,focus'), + ); + set_config('tooltiptrigger', '', 'qtype_formulas'); + $this->start_attempt_at_question($q, 'immediatefeedback', 1); + $this->check_current_output( + new \question_contains_tag_with_attribute('input', 'data-qtype-formulas-tooltip-trigger', ''), + ); + set_config('tooltiptrigger', 'hover', 'qtype_formulas'); + $this->start_attempt_at_question($q, 'immediatefeedback', 1); + $this->check_current_output( + new \question_contains_tag_with_attribute('input', 'data-qtype-formulas-tooltip-trigger', 'hover'), + ); + } + public function test_render_mc_accessibility_labels(): void { // Create a single part multiple choice (radio) question. $q = $this->get_test_formulas_question('testmc'); From df5ae92b404ddd439a43cdb9bac9de44bb7aaec4 Mon Sep 17 00:00:00 2001 From: Philipp Imhof <52650214+PhilippImhof@users.noreply.github.com> Date: Mon, 16 Mar 2026 13:43:37 +0100 Subject: [PATCH 4/4] fix empty line (lint) --- tests/behat/tooltips.feature | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/behat/tooltips.feature b/tests/behat/tooltips.feature index b0dc2cb3..673d0ce1 100644 --- a/tests/behat/tooltips.feature +++ b/tests/behat/tooltips.feature @@ -159,4 +159,3 @@ Feature: Display of tooltips And I set the field "Answer" to "5 m/s" Then I should not see "Number and unit" And "" "qtype_formulas > tooltip" should not be visible -