|
| 1 | +/* |
| 2 | + * This file is part of the QuestionPy Moodle plugin - https://questionpy.org |
| 3 | + * |
| 4 | + * Moodle is free software: you can redistribute it and/or modify |
| 5 | + * it under the terms of the GNU General Public License as published by |
| 6 | + * the Free Software Foundation, either version 3 of the License, or |
| 7 | + * (at your option) any later version. |
| 8 | + * |
| 9 | + * Moodle is distributed in the hope that it will be useful, |
| 10 | + * but WITHOUT ANY WARRANTY; without even the implied warranty of |
| 11 | + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
| 12 | + * GNU General Public License for more details. |
| 13 | + * |
| 14 | + * You should have received a copy of the GNU General Public License |
| 15 | + * along with Moodle. If not, see <http://www.gnu.org/licenses/>. |
| 16 | + */ |
| 17 | + |
| 18 | +import $ from "jquery"; |
| 19 | +import "theme_boost/bootstrap/popover"; |
| 20 | +import {get_string as getString} from "core/str"; |
| 21 | + |
| 22 | +/** |
| 23 | + * If the given input(-like) element is labelled, returns the label element. Returns null otherwise. |
| 24 | + * |
| 25 | + * @param {HTMLElement} input |
| 26 | + * @return {HTMLLabelElement | null} |
| 27 | + * @see {@link https://html.spec.whatwg.org/multipage/forms.html#the-label-element} |
| 28 | + */ |
| 29 | +function getLabelFor(input) { |
| 30 | + // A label can reference its labeled control in its for attribute. |
| 31 | + const id = input.id; |
| 32 | + if (id !== "") { |
| 33 | + const label = document.querySelector(`label[for='${id}']`); |
| 34 | + if (label) { |
| 35 | + return label; |
| 36 | + } |
| 37 | + } |
| 38 | + |
| 39 | + // Or the labeled control can be a descendant of the label. |
| 40 | + const label = input.closest("label"); |
| 41 | + if (label) { |
| 42 | + return label; |
| 43 | + } |
| 44 | + |
| 45 | + return null; |
| 46 | +} |
| 47 | + |
| 48 | +/** |
| 49 | + * Marks the given input element as invalid. |
| 50 | + * |
| 51 | + * @param {HTMLElement} element |
| 52 | + * @param {string} message validation message to show |
| 53 | + * @param {boolean} ariaInvalid |
| 54 | + */ |
| 55 | +function markInvalid(element, message, ariaInvalid = true) { |
| 56 | + element.classList.add("is-invalid"); |
| 57 | + if (ariaInvalid) { |
| 58 | + element.setAttribute("aria-invalid", "true"); |
| 59 | + } else { |
| 60 | + element.removeAttribute("aria-invalid"); |
| 61 | + } |
| 62 | + |
| 63 | + let popoverTarget = element; |
| 64 | + if (element.type === "checkbox" || element.type === "radio") { |
| 65 | + // Checkboxes and radios make for a very small hit area for the popover, so we attach the popover to the label. |
| 66 | + const label = getLabelFor(element); |
| 67 | + if (!label) { |
| 68 | + // No label -> Add the popover just to the checkbox. |
| 69 | + popoverTarget = element; |
| 70 | + } else if (label.contains(element)) { |
| 71 | + // Label contains checkbox -> Add the popover just to the label. |
| 72 | + popoverTarget = label; |
| 73 | + } else { |
| 74 | + // Separate label and checkbox -> Add the popover to both. |
| 75 | + popoverTarget = [element, label]; |
| 76 | + } |
| 77 | + } |
| 78 | + |
| 79 | + $(popoverTarget).popover({ |
| 80 | + toggle: "popover", |
| 81 | + trigger: "hover", |
| 82 | + content: message |
| 83 | + }); |
| 84 | +} |
| 85 | + |
| 86 | +/** |
| 87 | + * Undoes what {@link markInvalid} did. |
| 88 | + * |
| 89 | + * @param {HTMLInputElement} element |
| 90 | + */ |
| 91 | +function unmarkInvalid(element) { |
| 92 | + element.classList.remove("is-invalid"); |
| 93 | + element.removeAttribute("aria-invalid"); |
| 94 | + |
| 95 | + $([element, getLabelFor(element)]).popover("dispose"); |
| 96 | +} |
| 97 | + |
| 98 | +/** |
| 99 | + * Softly (i.e. without preventing form submission) validates constraints on the given element. |
| 100 | + * |
| 101 | + * @param {HTMLInputElement} element |
| 102 | + */ |
| 103 | +async function checkConditions(element) { |
| 104 | + if (element.dataset.qpy_required !== undefined) { |
| 105 | + let isPresent; |
| 106 | + if (element.type === "checkbox" || element.type === "radio") { |
| 107 | + isPresent = element.checked; |
| 108 | + } else { |
| 109 | + isPresent = element.value !== null && element.value !== ""; |
| 110 | + } |
| 111 | + |
| 112 | + if (!isPresent) { |
| 113 | + const message = await getString("input_missing", "qtype_questionpy"); |
| 114 | + // Aria-invalid shouldn't be set for missing inputs until the user has tried to submit them. |
| 115 | + // https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Attributes/aria-invalid |
| 116 | + markInvalid(element, message, false); |
| 117 | + return; |
| 118 | + } |
| 119 | + } |
| 120 | + |
| 121 | + const pattern = element.dataset.qpy_pattern; |
| 122 | + if (pattern !== undefined && element.value !== null && element.value !== "" |
| 123 | + && !element.value.match(`^(?:${pattern})$`)) { |
| 124 | + const message = await getString("input_invalid", "qtype_questionpy"); |
| 125 | + markInvalid(element, message); |
| 126 | + return; |
| 127 | + } |
| 128 | + |
| 129 | + const minLength = element.dataset.qpy_minlength; |
| 130 | + if (minLength !== undefined && element.value !== null && element.value !== "" |
| 131 | + && element.value.length < parseInt(minLength)) { |
| 132 | + const message = await getString("input_too_short", "qtype_questionpy", minLength); |
| 133 | + markInvalid(element, message); |
| 134 | + return; |
| 135 | + } |
| 136 | + |
| 137 | + const maxLength = element.dataset.qpy_maxlength; |
| 138 | + if (maxLength !== undefined && element.value !== null && element.value !== "" |
| 139 | + && element.value.length > parseInt(maxLength)) { |
| 140 | + const message = await getString("input_too_long", "qtype_questionpy", maxLength); |
| 141 | + markInvalid(element, message); |
| 142 | + return; |
| 143 | + } |
| 144 | + |
| 145 | + unmarkInvalid(element); |
| 146 | +} |
| 147 | + |
| 148 | +/** |
| 149 | + * Adds change event handlers for soft validation. |
| 150 | + */ |
| 151 | +export async function init() { |
| 152 | + for (const element of document |
| 153 | + .querySelectorAll("[data-qpy_required], [data-qpy_pattern], [data-qpy_minlength], [data-qpy_maxlength]")) { |
| 154 | + await checkConditions(element); |
| 155 | + element.addEventListener("change", event => checkConditions(event.target)); |
| 156 | + } |
| 157 | +} |
0 commit comments