Skip to content
Snippets Groups Projects
Unverified Commit f8119b44 authored by Niklas Mohrin's avatar Niklas Mohrin Committed by GitHub
Browse files

Merge pull request #1656 from niklasmohrin/element_as_htmlelement

Fix various small mistakes with keyboard tabbing logic - also port to TypeScript
parents 37d4f5e7 9235e18c
Branches
No related tags found
No related merge requests found
...@@ -5,6 +5,7 @@ ...@@ -5,6 +5,7 @@
*.swp *.swp
TAGS TAGS
.* .*
!.gitignore
*.kpf *.kpf
*.komodoproject *.komodoproject
...@@ -23,10 +24,6 @@ thumbs.db ...@@ -23,10 +24,6 @@ thumbs.db
evap/localsettings.py evap/localsettings.py
evap/static/css/evap.css evap/static/css/evap.css
evap/static/css/evap.css.map evap/static/css/evap.css.map
evap/static/js/copy-to-clipboard.js
evap/static/js/csrf-utils.js
evap/static/js/datagrid.js
evap/static/js/text-answer-warnings.js
evap/static/ts/rendered evap/static/ts/rendered
evap/static_collected evap/static_collected
evap/media evap/media
...@@ -44,7 +41,6 @@ htmlcov ...@@ -44,7 +41,6 @@ htmlcov
src src
!evap/static/ts/src !evap/static/ts/src
# node modules
node_modules node_modules
# python env subdir # python env subdir
......
# ignore the typescript output, but still track the libraries
*.js
!*.min.js
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) => {
if (e.ctrlKey || e.altKey) {
return;
}
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 @@ ...@@ -89,14 +89,14 @@
<div class="card collapsible{% if not forloop.last %} mb-3{% endif %}"> <div class="card collapsible{% if not forloop.last %} mb-3{% endif %}">
<div class="card-header d-flex tab-row"> <div class="card-header d-flex tab-row">
<div class="me-auto"> <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"> data-bs-target="#vote-area-{{ contributor.id }}" aria-expanded="false" aria-controls="vote-area-{{ contributor.id }}" tabindex="-1" type="button">
{{ contributor.full_name }} {{ contributor.full_name }}
{% if label %} &ndash; <span class="fst-italic">{{ label }}</span>{% endif %} {% if label %} &ndash; <span class="fst-italic">{{ label }}</span>{% endif %}
</button> </button>
</div> </div>
<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 }}" data-mark-no-answers-for="{{ contributor.id }}"
{% if preview %}disabled{% endif %} {% if preview %}disabled{% endif %}
> >
...@@ -275,16 +275,9 @@ ...@@ -275,16 +275,9 @@
sisyphus.manuallyReleaseData(); sisyphus.manuallyReleaseData();
window.location.replace("{{ success_redirect_url }}"); window.location.replace("{{ success_redirect_url }}");
} else { } else {
window.scrollTo({ // resubmit without this handler to show the site with the form errors
top: 0, form.unbind("submit");
behavior: "auto", form.submit();
});
// 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);
} }
}, },
error: function(data) { error: function(data) {
...@@ -317,7 +310,7 @@ ...@@ -317,7 +310,7 @@
sisyphus.saveAllData(); sisyphus.saveAllData();
// hide questionnaire for contributor // hide questionnaire for contributor
var voteAreaCollapse = bootstrap.Collapse.getOrCreateInstance(voteArea); const voteAreaCollapse = bootstrap.Collapse.getOrCreateInstance(voteArea);
voteAreaCollapse.hide(); voteAreaCollapse.hide();
collapseToggle.classList.add("tab-selectable"); collapseToggle.classList.add("tab-selectable");
...@@ -334,11 +327,19 @@ ...@@ -334,11 +327,19 @@
button.disabled = false; 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 // remove error highlighting when an answer was selected
document.querySelectorAll(".vote-btn.choice-error").forEach(voteButton => { document.querySelectorAll(".vote-btn.choice-error").forEach(voteButton => {
voteButton.addEventListener("click", () => clearChoiceError(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 => { document.querySelectorAll(".btn-textanswer").forEach(textanswerButton => {
...@@ -376,143 +377,7 @@ ...@@ -376,143 +377,7 @@
sisyphus.saveAllData(); 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>
<script type="module" src="{% static 'js/student-vote.js' %}"></script>
{% endif %} {% endif %}
{% endblock %} {% endblock %}
...@@ -25,7 +25,7 @@ ...@@ -25,7 +25,7 @@
</span> </span>
{% if allows_textanswer %} {% if allows_textanswer %}
<span class="my-auto" data-bs-toggle="tooltip" data-container=".col-question" title="{% trans 'Add a text answer to this question' %}"> <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> <span class="far fa-comment"></span>
</button> </button>
</span> </span>
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please to comment