Skip to content

Commit c7056d8

Browse files
MHajohaMartinGauk
authored andcommitted
feat: replace HTML validation attrs with soft validation
We mustn't prevent form submission, but we still want to give some feedback to the user. Closes #57
1 parent c8af471 commit c7056d8

12 files changed

+335
-36
lines changed

amd/build/view_question.min.js

+3
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

amd/build/view_question.min.js.map

+1
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

amd/src/view_question.js

+146
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
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+
21+
/**
22+
* If the given input(-like) element is labelled, returns the label element. Returns null otherwise.
23+
*
24+
* @param {HTMLElement} input
25+
* @return {HTMLLabelElement | null}
26+
* @see {@link https://html.spec.whatwg.org/multipage/forms.html#the-label-element}
27+
*/
28+
function getLabelFor(input) {
29+
// A label can reference its labeled control in its for attribute.
30+
const id = input.id;
31+
if (id !== "") {
32+
const label = document.querySelector(`label[for='${id}']`);
33+
if (label) {
34+
return label;
35+
}
36+
}
37+
38+
// Or the labeled control can be a descendant of the label.
39+
const label = input.closest("label");
40+
if (label) {
41+
return label;
42+
}
43+
44+
return null;
45+
}
46+
47+
/**
48+
* Marks the given input element as invalid.
49+
*
50+
* @param {HTMLElement} element
51+
* @param {string} message validation message to show
52+
* @param {boolean} ariaInvalid
53+
*/
54+
function markInvalid(element, message, ariaInvalid = true) {
55+
element.classList.add("is-invalid");
56+
if (ariaInvalid) {
57+
element.setAttribute("aria-invalid", "true");
58+
} else {
59+
element.removeAttribute("aria-invalid");
60+
}
61+
62+
let popoverTarget = element;
63+
if (element.type === "checkbox" || element.type === "radio") {
64+
// Checkboxes and radios make for a very small hit area for the popover, so we attach the popover to the label.
65+
const label = getLabelFor(element);
66+
if (!label) {
67+
// No label -> Add the popover just to the checkbox.
68+
popoverTarget = element;
69+
} else if (label.contains(element)) {
70+
// Label contains checkbox -> Add the popover just to the label.
71+
popoverTarget = label;
72+
} else {
73+
// Separate label and checkbox -> Add the popover to both.
74+
popoverTarget = [element, label];
75+
}
76+
}
77+
78+
$(popoverTarget).popover({
79+
toggle: "popover",
80+
trigger: "hover",
81+
content: message
82+
});
83+
}
84+
85+
/**
86+
* Undoes what {@link markInvalid} did.
87+
*
88+
* @param {HTMLInputElement} element
89+
*/
90+
function unmarkInvalid(element) {
91+
element.classList.remove("is-invalid");
92+
element.removeAttribute("aria-invalid");
93+
94+
$([element, getLabelFor(element)]).popover("dispose");
95+
}
96+
97+
/**
98+
* Softly (i.e. without preventing form submission) validates constraints on the given element.
99+
*
100+
* @param {HTMLInputElement} element
101+
*/
102+
async function checkConstraints(element) {
103+
/* Our goal here is to show helpful localised validation messages without actually preventing form submission.
104+
One way to achieve this would be to add the attribute "novalidate" to the form element, but that might interfere
105+
with other questions (since they share the same form).
106+
We also don't want to reimplement the validation logic already implemented by browsers.
107+
Instead, the standard validation attributes are added, their validity checked, the message used to create a
108+
popover, and the attributes removed. */
109+
try {
110+
if ("qpy_required" in element.dataset) {
111+
element.setAttribute("required", "required");
112+
}
113+
for (const attr of ["pattern", "minlength", "maxlength", "min", "max"]) {
114+
if (`qpy_${attr}` in element.dataset) {
115+
element.setAttribute(attr, element.dataset[`qpy_${attr}`]);
116+
}
117+
}
118+
119+
const isValid = element.checkValidity();
120+
if (isValid) {
121+
unmarkInvalid(element);
122+
} else {
123+
// Aria-invalid shouldn't be set for missing inputs until the user has tried to submit them.
124+
// https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Attributes/aria-invalid
125+
markInvalid(element, element.validationMessage, !element.validity.valueMissing);
126+
}
127+
} finally {
128+
for (const attr of ["required", "pattern", "minlength", "maxlength", "min", "max"]) {
129+
element.removeAttribute(attr);
130+
}
131+
}
132+
}
133+
134+
/**
135+
* Adds change event handlers for soft validation.
136+
*/
137+
export async function init() {
138+
for (const element of document.querySelectorAll(`
139+
[data-qpy_required], [data-qpy_pattern],
140+
[data-qpy_minlength], [data-qpy_maxlength],
141+
[data-qpy_min], [data-qpy_max]
142+
`)) {
143+
await checkConstraints(element);
144+
element.addEventListener("change", event => checkConstraints(event.target));
145+
}
146+
}

classes/question_metadata.php

+11-1
Original file line numberDiff line numberDiff line change
@@ -38,15 +38,25 @@ class question_metadata {
3838
*/
3939
public array $expecteddata = [];
4040

41+
/**
42+
* @var string[] an array of required field names
43+
* @see \question_manually_gradable::is_complete_response()
44+
* @see \question_manually_gradable::is_gradable_response()
45+
*/
46+
public array $requiredfields = [];
47+
4148
/**
4249
* Initializes a new instance.
4350
*
4451
* @param array|null $correctresponse if known, an array of `name => correct_value` entries for the expected
4552
* response fields
4653
* @param array $expecteddata an array of `name => PARAM_X` entries for the expected response fields
54+
* @param string[] $requiredfields an array of required field names
4755
*/
48-
public function __construct(?array $correctresponse = null, array $expecteddata = []) {
56+
public function __construct(?array $correctresponse = null, array $expecteddata = [],
57+
array $requiredfields = []) {
4958
$this->correctresponse = $correctresponse;
5059
$this->expecteddata = $expecteddata;
60+
$this->requiredfields = $requiredfields;
5161
}
5262
}

classes/question_ui_renderer.php

+55
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,10 @@ public function get_metadata(): question_metadata {
182182
$name = $element->getAttribute("name");
183183
if ($name) {
184184
$this->metadata->expecteddata[$name] = PARAM_RAW;
185+
186+
if ($element->hasAttribute("required")) {
187+
$this->metadata->requiredfields[] = $name;
188+
}
185189
}
186190
}
187191
}
@@ -215,6 +219,7 @@ private function render_part(DOMNode $part, question_attempt $qa, ?question_disp
215219
try {
216220
$this->hide_unwanted_feedback($xpath, $options);
217221
$this->set_input_values_and_readonly($xpath, $qa, $options);
222+
$this->soften_validation($xpath);
218223
$this->shuffle_contents($xpath);
219224
$this->add_styles($xpath);
220225
$this->mangle_ids_and_names($xpath, $qa);
@@ -451,6 +456,56 @@ private function resolve_placeholders(DOMXPath $xpath): void {
451456
}
452457
}
453458

459+
/**
460+
* Replaces the HTML attributes `pattern`, `required`, `minlength`, `maxlength` so that submission is not prevented.
461+
*
462+
* The standard attributes are replaced with `data-qpy_X`, which are then evaluated in JS.
463+
* Ideally we'd also want to handle min and max here, but their evaluation in JS would be quite complicated.
464+
*
465+
* @param DOMXPath $xpath
466+
* @return void
467+
*/
468+
private function soften_validation(DOMXPath $xpath): void {
469+
/** @var DOMElement $element */
470+
foreach ($xpath->query("//xhtml:input[@pattern]") as $element) {
471+
$pattern = $element->getAttribute("pattern");
472+
$element->removeAttribute("pattern");
473+
$element->setAttribute("data-qpy_pattern", $pattern);
474+
}
475+
476+
foreach ($xpath->query("(//xhtml:input | //xhtml:select | //xhtml:textarea)[@required]") as $element) {
477+
$element->removeAttribute("required");
478+
$element->setAttribute("data-qpy_required", "data-qpy_required");
479+
$element->setAttribute("aria-required", "true");
480+
}
481+
482+
foreach ($xpath->query("(//xhtml:input | //xhtml:textarea)[@minlength]") as $element) {
483+
$minlength = $element->getAttribute("minlength");
484+
$element->removeAttribute("minlength");
485+
$element->setAttribute("data-qpy_minlength", $minlength);
486+
}
487+
488+
foreach ($xpath->query("(//xhtml:input | //xhtml:textarea)[@maxlength]") as $element) {
489+
$maxlength = $element->getAttribute("maxlength");
490+
$element->removeAttribute("maxlength");
491+
$element->setAttribute("data-qpy_maxlength", $maxlength);
492+
}
493+
494+
foreach ($xpath->query("//xhtml:input[@min]") as $element) {
495+
$min = $element->getAttribute("min");
496+
$element->removeAttribute("min");
497+
$element->setAttribute("data-qpy_min", $min);
498+
$element->setAttribute("aria-valuemin", $min);
499+
}
500+
501+
foreach ($xpath->query("//xhtml:input[@max]") as $element) {
502+
$max = $element->getAttribute("max");
503+
$element->removeAttribute("max");
504+
$element->setAttribute("data-qpy_max", $max);
505+
$element->setAttribute("aria-valuemax", $max);
506+
}
507+
}
508+
454509
/**
455510
* Adds CSS classes to various elements to style them similarly to Moodle's own question types.
456511
*

0 commit comments

Comments
 (0)