Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions web/assets/site.css
Original file line number Diff line number Diff line change
Expand Up @@ -790,6 +790,54 @@ table.data.data-wide { min-width: 820px; }
}

/* ---------- search modal ---------- */
/* ---------- resource table ---------- */
/* the topic filter is a select rather than a chip row, so it needs the same
height and border as the search field beside it without the icon inset */
.toolbar .field-select { flex: 0 1 auto; min-width: 0; }
.toolbar .field-select select {
height: 40px; border: 1px solid var(--border); border-radius: var(--radius-md);
background-color: var(--surface); color: var(--text);
}
.field-select select {
appearance: none; -webkit-appearance: none;
border: 0; background: transparent; color: inherit;
font: inherit; padding: 9px 30px 9px 12px; max-width: 15rem;
background-image: linear-gradient(45deg, transparent 50%, currentColor 50%), linear-gradient(135deg, currentColor 50%, transparent 50%);
background-position: right 14px center, right 9px center;
background-size: 5px 5px, 5px 5px;
background-repeat: no-repeat;
}
.res-table {
width: 100%; border-collapse: collapse; font-size: 0.9rem;
margin-top: 18px; border: 1px solid var(--border); border-radius: var(--radius-lg);
background: var(--surface);
/* no overflow property here: any value other than visible makes this a scroll
container, and a sticky thead then offsets inside the table instead of
against the page, leaving a 58px void above the header */
}
.res-table th, .res-table td { text-align: left; vertical-align: top; padding: 9px 12px; border-bottom: 1px solid var(--border); }
.res-table thead th { position: sticky; top: 58px; z-index: 1; background: var(--surface-alt); border-bottom: 1px solid var(--border-strong); }
.res-table thead th button {
display: inline-flex; align-items: center; gap: 5px;
border: 0; background: none; padding: 0; cursor: pointer;
font: inherit; font-weight: 700; color: var(--text);
}
.res-table thead th button::after { content: "↕"; opacity: 0.35; font-size: 0.85em; }
.res-table thead th[aria-sort="ascending"] button::after { content: "↑"; opacity: 1; color: var(--accent-strong); }
.res-table thead th[aria-sort="descending"] button::after { content: "↓"; opacity: 1; color: var(--accent-strong); }
.res-table tbody tr:hover { background: var(--surface-alt); }
.res-table td[data-col="title"] { width: 52%; }
.res-table td[data-col="publisher"], .res-table td[data-col="topic"] { white-space: nowrap; }
.res-pub { color: var(--text-faint); font-size: 0.85em; }
.res-note { display: block; margin-top: 2px; color: var(--text-faint); font-size: 0.85em; line-height: 1.45; }
@media (max-width: 720px) {
/* the publisher is the first thing to go: it is the least load-bearing
column and the title already carries the link */
.res-table td[data-col="publisher"], .res-table th:nth-child(2) { display: none; }
.res-table td[data-col="title"] { width: auto; }
.res-table td[data-col="topic"] { white-space: normal; }
}

/* ---------- lightbox ---------- */
.lightbox { position: fixed; inset: 0; z-index: 110; display: none; }
.lightbox.open { display: block; }
Expand Down
103 changes: 101 additions & 2 deletions web/assets/site.js
Original file line number Diff line number Diff line change
Expand Up @@ -279,6 +279,7 @@
modal.classList.add("open");
document.body.style.overflow = "hidden";
input.value = ""; render(""); input.focus();
loadIndex().then(() => { if (modal.classList.contains("open")) render(input.value); });
};
const close = () => {
modal.classList.remove("open"); sel = -1;
Expand Down Expand Up @@ -317,6 +318,27 @@
return sc;
};

/* The index holds every page, prompt, worksheet, section and resource --
around 22 KB gzipped once the 653 resources are in it. Every page used to
block on that whether or not anyone searched. It is fetched on the first
time the dialog opens instead, and the page carries a prefetch hint so the
browser can pull it while idle. Falls back silently: if the fetch fails
there is simply nothing to search rather than a broken dialog. */
let indexPromise = null;
const loadIndex = () => {
if (window.SEARCH_INDEX) return Promise.resolve();
if (indexPromise) return indexPromise;
const src = window.SEARCH_INDEX_URL;
if (!src) return Promise.resolve();
indexPromise = new Promise((resolve) => {
const s = document.createElement("script");
s.src = src;
s.onload = s.onerror = () => resolve();
document.head.appendChild(s);
});
return indexPromise;
};

const mark = (text, q) => {
if (!q) return esc(text);
const i = text.toLowerCase().indexOf(q);
Expand Down Expand Up @@ -384,14 +406,21 @@
if (!cards.length) return;

const input = $(opts.input);
const chips = $$(opts.chips, grid.closest(opts.scope || "body") || document);
const scope = grid.closest(opts.scope || "body") || document;
const all = $$(opts.chips, scope);
/* a filter group is driven either by a row of chips or by one select --
the select earns its place when the group has fifty values and a chip row
would be longer than the table it filters */
const chips = all.filter((el) => el.tagName !== "SELECT");
const selects = all.filter((el) => el.tagName === "SELECT");
const countEl = $(opts.count);
const emptyEl = $(opts.empty);
const one = opts.one || "item";
const many = opts.many || "items";

const groups = {};
chips.forEach((c) => { groups[c.dataset.filterGroup] = ""; });
selects.forEach((c) => { groups[c.dataset.filterGroup] = ""; });
let q = "";

// data-filter-group="time-cost" reads data-time-cost off the card
Expand All @@ -417,6 +446,7 @@
const on = (c.dataset.filterValue || "") === groups[c.dataset.filterGroup];
c.setAttribute("aria-pressed", String(on));
});
selects.forEach((c) => { c.value = groups[c.dataset.filterGroup] || ""; });
if (countEl) countEl.innerHTML = `Showing <b>${shown}</b> of ${cards.length} ${cards.length === 1 ? one : many}`;
if (emptyEl) emptyEl.hidden = shown > 0;
};
Expand All @@ -425,6 +455,7 @@
Object.keys(groups).forEach((g) => { groups[g] = ""; });
q = "";
if (input) input.value = "";
selects.forEach((c) => { c.value = ""; });
apply();
};

Expand All @@ -435,13 +466,20 @@
groups[g] = groups[g] === v ? "" : v; // clicking the live chip clears its group
apply();
}));
selects.forEach((c) => c.addEventListener("change", () => {
groups[c.dataset.filterGroup] = c.value;
apply();
}));
$$("[data-filter-reset]").forEach((b) => b.addEventListener("click", reset));

// deep link, e.g. library.html?q=recap&stage=build
const params = new URLSearchParams(location.search);
Object.keys(groups).forEach((g) => {
const v = params.get(g);
if (v && chips.some((c) => c.dataset.filterGroup === g && c.dataset.filterValue === v)) groups[g] = v;
if (!v) return;
const okChip = chips.some((c) => c.dataset.filterGroup === g && c.dataset.filterValue === v);
const okOpt = selects.some((c) => c.dataset.filterGroup === g && [...c.options].some((o) => o.value === v));
if (okChip || okOpt) groups[g] = v;
});
if (params.get("q")) { q = params.get("q").trim().toLowerCase(); if (input) input.value = params.get("q"); }

Expand Down Expand Up @@ -531,6 +569,67 @@
});
})();

/* ---------- sortable tables ----------
Any table[data-sortable] whose header cells contain a button[data-sort].
Sorts on the matching td[data-col], comparing the cell's text. Rows hidden
by a filter keep their place in the sort, so clearing the filter does not
reveal an order that never made sense. */
$$("table[data-sortable]").forEach((table) => {
const body = $("tbody", table);
if (!body) return;
const buttons = $$("th button[data-sort]", table);
/* Adopt the order the markup already declares. Without this the first click
on the pre-sorted column re-applies the same direction and looks broken. */
const preset = $("th[aria-sort] button[data-sort]", table);
let col = preset ? preset.dataset.sort : null;
let dir = preset && preset.closest("th").getAttribute("aria-sort") === "descending" ? -1 : 1;

const key = (row, name) => {
const cell = $(`td[data-col="${name}"]`, row);
return (cell ? cell.textContent : "").trim().toLowerCase();
};

const sort = (name) => {
dir = col === name ? -dir : 1;
col = name;
const rows = [...body.rows];
/* Intl collation so accented titles land where a reader expects, and a
numeric pass so "10 Ways" does not sort before "2 Ways". */
const cmp = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" });
rows.sort((a, b) => {
const ka = key(a, name), kb = key(b, name);
/* An empty cell is absent data, not a value that sorts first. Reversing
the direction should not float a block of blanks to the top. */
if (!ka !== !kb) return ka ? -1 : 1;
return dir * cmp.compare(ka, kb);
});
const frag = document.createDocumentFragment();
rows.forEach((r) => frag.appendChild(r));
body.appendChild(frag);
buttons.forEach((b) => {
const th = b.closest("th");
if (!th) return;
if (b.dataset.sort === name) th.setAttribute("aria-sort", dir === 1 ? "ascending" : "descending");
else th.removeAttribute("aria-sort");
});
};

buttons.forEach((b) => b.addEventListener("click", () => sort(b.dataset.sort)));
});

/* the resource index: same filter engine as the prompt library, over rows */
mountFilterGrid({
grid: "#res-body",
cards: "#res-body > tr",
input: "#res-q",
chips: "[data-filter-group]",
scope: "main",
count: "#res-count",
empty: "#res-empty",
one: "resource",
many: "resources",
});

/* the prompt library is the one grid using it today */
mountFilterGrid({
grid: "#lib-grid",
Expand Down
Loading
Loading