Files
beer_data/src/flask/static/js/app.js
T
hermes 27b2611a20 fix(ui): keep the table columns readable on a phone
The tables were squeezed to fit the screen, and with seven to nine
columns on a 390px phone every heading and every value was truncated to
a few characters - a gravity of "1.0..." and a date of "202..." are
worse than useless, and the beer name was painted over the gravity
reading because the cell was allowed to overflow.

The tables keep their natural width and scroll sideways inside their
card, which is what a table does.  The advice is a small button so the
column that matters is narrow, and the headings have a short form for
a phone ("Brewing advice" -> "Advice", "Attenuation" -> "Att.").

The secondary columns - the drop, the legacy ABV and the days - go away
on a phone; the details are still on the run's own page.

A name and a comment get a sensible minimum width and wrap on a word
boundary rather than one word per line.

The desktop is untouched: the same full headings, the same 806px table,
and the pop-out still opens on hover.
2026-11-09 21:48:07 +10:00

224 lines
8.4 KiB
JavaScript

/* ==========================================================================
Brew Log — page behaviour
Small, dependency-free helpers. Charts live in charts.js.
========================================================================== */
(function () {
"use strict";
/** Auto-focus the first empty field on a form. */
function focusFirstEmpty() {
var fields = document.querySelectorAll("input:not([type=hidden]), textarea, select");
for (var i = 0; i < fields.length; i++) {
if (!fields[i].value && !fields[i].readOnly) {
fields[i].focus();
return;
}
}
}
/** Default the reading date to today when the field is left empty. */
function defaultDateFields() {
var today = new Date().toISOString().slice(0, 10);
var dateFields = document.querySelectorAll('input[type="date"][data-default-today]');
for (var i = 0; i < dateFields.length; i++) {
if (!dateFields[i].value) {
dateFields[i].value = today;
}
}
}
/** Show/hide blocks driven by a checkbox. */
function wireToggles() {
var toggles = document.querySelectorAll("[data-toggle-target]");
for (var i = 0; i < toggles.length; i++) {
(function (toggle) {
var target = document.getElementById(toggle.getAttribute("data-toggle-target"));
if (!target) { return; }
function apply() {
target.classList.toggle("hidden", !toggle.checked);
}
toggle.addEventListener("change", apply);
apply();
})(toggles[i]);
}
}
/** Confirm destructive actions. */
function wireConfirms() {
var forms = document.querySelectorAll("[data-confirm]");
for (var i = 0; i < forms.length; i++) {
forms[i].addEventListener("submit", function (event) {
if (!window.confirm(this.getAttribute("data-confirm"))) {
event.preventDefault();
}
});
}
}
/**
* The brewing advice pop-out.
*
* On a desktop the panel opens on hover, which the CSS does. A
* phone has no hover, so the trigger is a button that toggles it -
* and it is a real button, so it works with a keyboard and with a
* screen reader.
*/
/**
* The brewing advice pop-out.
*
* The advice runs to several hundred characters. In a table cell it
* pushed the table past the width of a phone, so it is a compact
* trigger instead and the text is shown in a floating panel.
*
* The panel is position:fixed so it escapes the table's scroll
* container - absolutely positioned inside the cell it was clipped
* by the container and sliced in half by the card's edge.
*
* It opens on hover on a desktop, and on tap on a phone, which has
* no hover. The trigger is a real button, so it also works with a
* keyboard.
*/
function wireAdvicePopouts() {
var triggers = document.querySelectorAll(".advice-trigger");
for (var i = 0; i < triggers.length; i++) {
(function (trigger) {
var panel = trigger.parentNode.querySelector(".advice-pop");
if (!panel) { return; }
var closeTimer = null;
function isPhone() {
return window.matchMedia("(max-width: 720px)").matches;
}
function place() {
if (isPhone()) {
/* The CSS centres it on a phone - clear any
inline position left over from a desktop
resize. */
panel.style.top = "";
panel.style.left = "";
return;
}
var box = trigger.getBoundingClientRect();
var gap = 10;
var height = panel.offsetHeight;
var width = panel.offsetWidth;
var spaceBelow = window.innerHeight - box.bottom - gap;
var spaceAbove = box.top - gap;
/* Prefer below; flip above when there is not enough
room and there is more room above. */
var top;
if (height <= spaceBelow || spaceBelow >= spaceAbove) {
top = box.bottom + gap;
} else {
top = Math.max(gap, box.top - height - gap);
}
/* Keep it on screen horizontally. */
var left = Math.min(box.left, window.innerWidth - width - gap);
left = Math.max(gap, left);
panel.style.top = Math.round(top) + "px";
panel.style.left = Math.round(left) + "px";
}
function open() {
if (closeTimer) {
clearTimeout(closeTimer);
closeTimer = null;
}
panel.classList.add("is-open");
trigger.setAttribute("aria-expanded", "true");
place();
}
function close() {
panel.classList.remove("is-open");
trigger.setAttribute("aria-expanded", "false");
}
function closeSoon() {
/* A short delay, so the mouse can travel from the
trigger into the panel without it closing. */
if (closeTimer) { clearTimeout(closeTimer); }
closeTimer = setTimeout(function () {
closeTimer = null;
if (!panel.matches(":hover") && !trigger.matches(":hover")) {
close();
}
}, 180);
}
trigger.addEventListener("click", function (event) {
event.preventDefault();
event.stopPropagation();
if (panel.classList.contains("is-open")) {
close();
} else {
open();
}
});
trigger.addEventListener("mouseenter", function () {
if (!isPhone()) { open(); }
});
trigger.addEventListener("mouseleave", closeSoon);
panel.addEventListener("mouseenter", function () {
if (closeTimer) {
clearTimeout(closeTimer);
closeTimer = null;
}
});
panel.addEventListener("mouseleave", closeSoon);
var closeButton = panel.querySelector(".advice-pop__close");
if (closeButton) {
closeButton.addEventListener("click", function (event) {
event.preventDefault();
event.stopPropagation();
close();
trigger.focus();
});
}
document.addEventListener("keydown", function (event) {
if (event.key === "Escape" && panel.classList.contains("is-open")) {
close();
trigger.focus();
}
});
document.addEventListener("click", function (event) {
if (!panel.contains(event.target) && event.target !== trigger) {
close();
}
});
window.addEventListener("resize", function () {
if (panel.classList.contains("is-open")) { place(); }
});
window.addEventListener("scroll", function () {
if (panel.classList.contains("is-open")) { close(); }
}, true);
})(triggers[i]);
}
}
function init() {
focusFirstEmpty();
defaultDateFields();
wireToggles();
wireConfirms();
wireAdvicePopouts();
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init);
} else {
init();
}
})();