Skip to content
Snippets Groups Projects
Unverified Commit 26eac87c authored by Niklas Mohrin's avatar Niklas Mohrin
Browse files

Fix various small mistakes with keyboard tabbing logic - also port to TypeScript

- Port keyboard logic for student_vote.html to TypeScript
- Disallow tabbing onto the "additional textanswer" button
- Select the correct input when first tabbing into the form area
- Reload student vote site on form errors
- Before this change, we would use the HTML that we got back from the AJAX submission and just rewrite the DOM with `document.write`. This is ... controversial.
- okay, I guess we can also let people back out of the form again if you really insist
- _inlines return_; niklas: 'is this test driven development?'
- wow, this was broken on master all along
- just throwing requestAnimationFrame at the problem, 100% success rate so far
- Inline variable, outline my skills
- Rename selectable{, s}
parent 96e851b8
Branches
No related tags found
No related merge requests found
function isInvisible(el: Element): boolean {
if (getComputedStyle(el).display === "none")
return true;
return el.parentElement !== null && isInvisible(el.parentElement);
}
function selectByNumberKey(row: HTMLElement, num: number) {
let index = 2 * num - 1;
if (num === 0) {
// Select "No answer"
index = row.children.length - 1;
}
if (!(0 <= index && index < row.children.length)) {
return;
}
const nextElement = row.children[index] as HTMLElement;
nextElement.click();
}
const studentForm = document.getElementById("student-vote-form") as HTMLElement;
const selectables: NodeListOf<HTMLElement> = studentForm.querySelectorAll(".tab-selectable");
const rows = Array.from(studentForm.getElementsByClassName("tab-row")) as Array<HTMLElement>;
const letterRegex = new RegExp("^[A-Za-zÄÖÜäöü\.\*\+\-]$");
// Sometimes we just want the browser to do it's thing.
let disableFocusHandler = false;
selectables[0].addEventListener("focus", () => {
if (disableFocusHandler) {
disableFocusHandler = false;
return;
}
requestAnimationFrame(() => {
// When first entering the form area with the altered tabbing rules, we
// need to make sure that we start on the correct input.
const correctInput = findCorrectInputInRow(rows[0]);
if (selectables[0] !== correctInput) {
fancyFocus(correctInput);
}
});
});
studentForm.addEventListener("keydown", (e: KeyboardEvent) => {
const current = document.activeElement as HTMLElement;
if (!current.matches("input, label, span, textarea, button")) {
return;
}
if (current.tagName !== "TEXTAREA") {
// We want to disable backspace, because users may think that
// they can undo their selection, but they would be sent to the
// student index (or where they came from otherwise).
// Additionally, pressing Enter shouldn't submit the form.
switch (e.key) {
case "Enter":
current.click(); // fallthrough
case "Backspace":
e.preventDefault();
return;
}
}
// Since the event could be caught on either the outer label or
// the nested text / input, the full row could be two steps up
const currentRow: HTMLElement | null = current.closest(".tab-row");
if (currentRow === null) {
return;
}
const insideSubmitRow = currentRow.closest(".card-submit-area") !== null;
if (!insideSubmitRow && current.tagName !== "TEXTAREA") {
const num = parseInt(e.key);
if (!isNaN(num)) {
// Even if the number does not trigger a selection (for example pressing "9"),
// nothing else should happen, because it would be very frustrating if only some numbers
// would work as expected.
e.preventDefault();
selectByNumberKey(currentRow, num);
return;
}
}
if (e.key !== "Tab") {
if (current.tagName !== "TEXTAREA" && letterRegex.test(e.key)) {
const wholeRow = currentRow.closest("div.row");
if (wholeRow === null)
return;
e.preventDefault();
const textAnswerButton: HTMLElement | null = wholeRow.querySelector("[data-bs-toggle=\"collapse\"]");
const textField: HTMLTextAreaElement | null = wholeRow.querySelector("textarea.tab-selectable");
if (textAnswerButton !== null && textField !== null) {
if (isInvisible(textField))
textAnswerButton.click();
fancyFocus(textField);
textField.value += e.key;
textField.dispatchEvent(new Event("input"));
}
}
return;
}
const curRowIndex = rows.indexOf(currentRow);
const direction = e.shiftKey ? -1 : 1;
let nextRowIndex = curRowIndex;
do {
nextRowIndex += direction;
if (nextRowIndex === -1) {
// User wants to tab out in front of the form area
// To correctly select the first element in front of the form,
// select the first element tracked by this.
// Just giving back control to the browser here doesn't work, because
// it would navigate backwards through the controls of the current row.
disableFocusHandler = true;
selectables[0].focus({ preventScroll: true });
return;
} else if (nextRowIndex === rows.length) {
// User wants to tab out behind the form area
selectables[selectables.length - 1].focus({ preventScroll: true });
return;
}
} while (isInvisible(rows[nextRowIndex]));
e.preventDefault();
fancyFocus(findCorrectInputInRow(rows[nextRowIndex]));
});
function findCorrectInputInRow(row: HTMLElement) {
const alreadySelectedElement: HTMLElement = row.querySelector(".tab-selectable:checked")!;
if (alreadySelectedElement) {
return alreadySelectedElement;
} else {
const possibleTargets: NodeListOf<HTMLElement> = row.querySelectorAll(".tab-selectable");
if (possibleTargets.length === 3) {
// Yes-No / No-Yes question, should focus first element
return possibleTargets[0];
} else {
// Everything else: The middle of all the answers excluding "no answer"
// This also handles all the single possibility cases
const index = Math.floor((possibleTargets.length - 1) / 2);
return possibleTargets[index];
}
}
}
function fancyFocus(element: HTMLElement) {
element.focus({ preventScroll: true });
element.scrollIntoView({
behavior: "smooth",
block: "center",
});
}
......@@ -89,14 +89,14 @@
<div class="card collapsible{% if not forloop.last %} mb-3{% endif %}">
<div class="card-header d-flex tab-row">
<div class="me-auto">
<button class="collapse-toggle{% if errors_exist and not contributor_has_errors %} collapsed{% endif %} bg-transparent" data-bs-toggle="collapse"
<button class="collapse-toggle{% if errors_exist and not contributor_has_errors %} collapsed tab-selectable{% endif %} bg-transparent" data-bs-toggle="collapse"
data-bs-target="#vote-area-{{ contributor.id }}" aria-expanded="false" aria-controls="vote-area-{{ contributor.id }}" tabindex="-1" type="button">
{{ contributor.full_name }}
{% if label %} &ndash; <span class="fst-italic">{{ label }}</span>{% endif %}
</button>
</div>
<div>
<button type="button" class="btn btn-light btn-sm tab-selectable"
<button type="button" class="btn btn-light btn-sm{% if not preview %} tab-selectable{% endif %}"
data-mark-no-answers-for="{{ contributor.id }}"
{% if preview %}disabled{% endif %}
>
......@@ -275,16 +275,9 @@
sisyphus.manuallyReleaseData();
window.location.replace("{{ success_redirect_url }}");
} else {
window.scrollTo({
top: 0,
behavior: "auto",
});
// Manually perform the page load: first replace content
document.open();
document.write(data);
document.close();
// then set URL (e.g. logout redirect)
window.history.pushState('', '', xhr.responseURL);
// resubmit without this handler to show the site with the form errors
form.unbind("submit");
form.submit();
}
},
error: function(data) {
......@@ -317,7 +310,7 @@
sisyphus.saveAllData();
// hide questionnaire for contributor
var voteAreaCollapse = bootstrap.Collapse.getOrCreateInstance(voteArea);
const voteAreaCollapse = bootstrap.Collapse.getOrCreateInstance(voteArea);
voteAreaCollapse.hide();
collapseToggle.classList.add("tab-selectable");
......@@ -334,11 +327,19 @@
button.disabled = false;
});
});
collapseToggle.addEventListener("click", () => {
if (button.classList.contains("tab-selectable")) {
collapseToggle.classList.remove("tab-selectable");
}
});
});
// remove error highlighting when an answer was selected
document.querySelectorAll(".vote-btn.choice-error").forEach(voteButton => {
voteButton.addEventListener("click", () => clearChoiceError(voteButton));
const actualInput = document.getElementById(voteButton.attributes["for"].value);
actualInput.addEventListener("click", () => clearChoiceError(voteButton));
});
document.querySelectorAll(".btn-textanswer").forEach(textanswerButton => {
......@@ -376,143 +377,7 @@
sisyphus.saveAllData();
});
}
function isInvisible(el) {
if (getComputedStyle(el).display === "none")
return true;
return el.parentElement !== null && isInvisible(el.parentElement);
}
function selectByNumberKey(row, num) {
let index = 2 * num - 1;
if (num === 0) {
// Select "No answer"
index = row.children.length - 1;
}
if (!(0 <= index && index < row.children.length)) {
return;
}
const nextElement = row.children[index];
nextElement.click();
}
const studentForm = document.getElementById("student-vote-form");
const selectable = studentForm.querySelectorAll(".tab-selectable");
const rows = Array.from(studentForm.getElementsByClassName("tab-row"));
const letterRegex = new RegExp("^[A-Za-zÄÖÜäöü\.\*\+\-]$");
studentForm.addEventListener("keydown", e => {
const current = document.activeElement;
if (!current.matches("input, label, span, textarea, button")) {
return;
}
if (current.tagName !== "TEXTAREA") {
// We want to disable backspace, because users may think that
// they can undo their selection, but they would be sent to the
// student index (or where they came from otherwise).
// Additionally, pressing Enter shouldn't submit the form.
switch (e.key) {
case "Enter":
current.click(); // fallthrough
case "Backspace":
e.preventDefault();
return;
}
}
// Since the event could be caught on either the outer label or
// the nested text / input, the full row could be two steps up
const currentRow = current.closest(".tab-row");
if (currentRow === null) {
return;
}
const insideSubmitRow = currentRow.closest(".card-submit-area") !== null;
if (!insideSubmitRow && current.tagName !== "TEXTAREA") {
const num = parseInt(e.key);
if (!isNaN(num)) {
// Even if the number does not trigger a selection (for example pressing "9"),
// nothing else should happen, because it would be very frustrating if only some numbers
// would work as expected.
e.preventDefault();
selectByNumberKey(currentRow, num);
return;
}
}
if (e.key !== "Tab") {
if (current.tagName !== "TEXTAREA" && letterRegex.test(e.key)) {
const wholeRow = currentRow.closest("div.row");
if (wholeRow === null)
return;
e.preventDefault();
const textAnswerButton = wholeRow.querySelector("[data-bs-toggle=\"collapse\"]");
const textField = wholeRow.querySelector("textarea.tab-selectable");
if (textAnswerButton !== null && textField !== null) {
if (isInvisible(textField))
textAnswerButton.click();
textField.focus({ preventScroll: true });
textField.scrollIntoView({
behavior: "smooth",
block: "center",
});
textField.value += e.key;
textField.dispatchEvent(new Event("input"));
}
}
return;
}
const curRowIndex = rows.indexOf(currentRow);
const direction = e.shiftKey ? -1 : 1;
let nextRowIndex = curRowIndex;
do {
nextRowIndex += direction;
if (nextRowIndex === -1) {
// User wants to tab out in front of the form area
// To correctly select the first element in front of the form,
// select the first element tracked by this.
// Just giving back control to the browser here doesn't work, because
// it would navigate backwards through the controls of the current row.
selectable[0].focus({ preventScroll: true });
return;
} else if (nextRowIndex === rows.length) {
// User wants to tab out behind the form area
selectable[selectable.length - 1].focus({ preventScroll: true });
return;
}
} while (isInvisible(rows[nextRowIndex]));
const nextRow = rows[nextRowIndex];
e.preventDefault();
let nextElement;
const alreadySelectedElement = nextRow.querySelector(".tab-selectable:checked");
if (alreadySelectedElement) {
nextElement = alreadySelectedElement;
} else {
const possibleTargets = nextRow.querySelectorAll(".tab-selectable");
if (possibleTargets.length === 3) {
// Yes-No / No-Yes question, should focus first element
nextElement = possibleTargets[0];
} else {
// Everything else: The middle of all the answers excluding "no answer"
// This also handles all the single possibility cases
const index = Math.floor((possibleTargets.length - 1) / 2);
nextElement = possibleTargets[index];
}
}
nextElement.focus({ preventScroll: true });
nextElement.scrollIntoView({
behavior: "smooth",
block: "center",
});
});
</script>
<script type="module" src="{% static 'js/student-vote.js' %}"></script>
{% endif %}
{% endblock %}
......@@ -25,7 +25,7 @@
</span>
{% if allows_textanswer %}
<span class="my-auto" data-bs-toggle="tooltip" data-container=".col-question" title="{% trans 'Add a text answer to this question' %}">
<button type="button" class="btn btn-textanswer collapsed" data-bs-toggle="collapse" data-bs-target=".collapse-{{ field.name }}">
<button type="button" class="btn btn-textanswer collapsed" data-bs-toggle="collapse" data-bs-target=".collapse-{{ field.name }}" tabindex="-1">
<span class="far fa-comment"></span>
</button>
</span>
......
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please to comment