diff --git a/profiles/w3c.js b/profiles/w3c.js index cdf270af71..a9285a3844 100644 --- a/profiles/w3c.js +++ b/profiles/w3c.js @@ -61,6 +61,7 @@ const modules = [ import("../src/core/data-type.js"), import("../src/core/anchor-expander.js"), import("../src/core/dfn-panel.js"), + import("../src/core/sortable-table.js"), import("../src/core/custom-elements/index.js"), import("../src/core/web-monetization.js"), import("../src/core/dfn-contract.js"), diff --git a/src/core/sortable-table.js b/src/core/sortable-table.js new file mode 100644 index 0000000000..62e3205f89 --- /dev/null +++ b/src/core/sortable-table.js @@ -0,0 +1,28 @@ +// @ts-check +import css from "../styles/sortable-table.css.js"; +import { fetchBase } from "./text-loader.js"; + +export const name = "core/sortable-table"; + +export async function run() { + if (!document.querySelector("table.sortable")) { + return; + } + + const style = document.createElement("style"); + style.textContent = css; + document.head.insertBefore(style, document.querySelector("link")); + + const script = document.createElement("script"); + script.id = "respec-sortable-table"; + script.textContent = await loadScript(); + document.body.append(script); +} + +async function loadScript() { + try { + return (await import("text!./sortable-table.runtime.js")).default; + } catch { + return fetchBase("./src/core/sortable-table.runtime.js"); + } +} diff --git a/src/core/sortable-table.runtime.js b/src/core/sortable-table.runtime.js new file mode 100644 index 0000000000..07f7942a2c --- /dev/null +++ b/src/core/sortable-table.runtime.js @@ -0,0 +1,157 @@ +// @ts-check +if (document.respec) { + document.respec.ready.then(setupSortableTable); +} else { + setupSortableTable(); +} + +function setupSortableTable() { + const ASC = "ascending"; + const DESC = "descending"; + + /** @type {Record} */ + const NEXT_DIR = { [ASC]: DESC, [DESC]: null }; + + /** @type {WeakMap>} */ + const STATE = new WeakMap(); + + /** + * @param {HTMLTableCellElement} th + * @param {"ascending" | "descending" | null} dir + */ + const updateButton = (th, dir) => { + // Cache original label before button text is appended. + if (!th.dataset.label) { + th.dataset.label = th.textContent.trim(); + } + + let button = th.querySelector("button"); + if (!button) { + button = document.createElement("button"); + button.type = "button"; + th.append(button); + } + + const icon = dir === ASC ? "▲" : dir === DESC ? "▼" : "⇕"; + const label = th.dataset.label; + + if (dir) { + const opposite = dir === ASC ? DESC : ASC; + button.setAttribute( + "aria-label", + `Sorted ${dir} by ${label}. Click to sort ${opposite}.` + ); + th.setAttribute("aria-sort", dir); + } else { + button.setAttribute("aria-label", `Sort by ${label}`); + th.removeAttribute("aria-sort"); + } + button.textContent = icon; + }; + + /** @type {NodeListOf} */ + const tables = document.querySelectorAll("table.sortable"); + tables.forEach(table => { + if (!table.tHead) return; + table.tHead.querySelectorAll("th").forEach(th => updateButton(th, null)); + }); + + /** + * @param {MouseEvent} ev + * @returns {HTMLTableCellElement | null} + */ + const getTrigger = ev => { + if (!(ev.target instanceof HTMLElement)) return null; + const th = ev.target.closest("th"); + if (th instanceof HTMLTableCellElement && th.closest("table.sortable")) { + return th; + } + return null; + }; + + /** + * Stamps original row indices so the table can be reset to its initial order. + * @param {HTMLTableRowElement[]} rows + */ + const stampOriginalOrder = rows => { + rows.forEach((row, i) => { + if (!row.dataset.sortIndex) { + row.dataset.sortIndex = String(i); + } + }); + }; + + document.addEventListener("click", ev => { + const th = getTrigger(ev); + if (!th) return; + + /** @type {HTMLTableElement | null} */ + const table = th.closest("table.sortable"); + if (!table || !table.tBodies.length) return; + + let tableState = STATE.get(table); + if (!tableState) { + tableState = new WeakMap(); + STATE.set(table, tableState); + } + + const current = tableState.get(th) ?? null; + /** @type {"ascending" | "descending" | null} */ + const next = current === null ? ASC : NEXT_DIR[current]; + + if (table.tHead) { + table.tHead.querySelectorAll("th").forEach(otherTh => { + if (otherTh !== th) { + tableState.delete(otherTh); + updateButton(otherTh, null); + } + }); + } + + if (next === null) { + tableState.delete(th); + } else { + tableState.set(th, next); + } + updateButton(th, next); + + const tbody = table.tBodies[0]; + /** @type {HTMLTableRowElement[]} */ + const rows = Array.from(tbody.rows); + stampOriginalOrder(rows); + + if (next === null) { + rows.sort( + (a, b) => + parseInt(/** @type {string} */ (a.dataset.sortIndex), 10) - + parseInt(/** @type {string} */ (b.dataset.sortIndex), 10) + ); + } else { + /** @type {HTMLTableRowElement | null} */ + const headerRow = /** @type {HTMLTableRowElement | null} */ ( + th.closest("tr") + ); + if (!headerRow) return; + const colIndex = Array.from(headerRow.cells).indexOf(th); + if (colIndex === -1) return; + + const isNumeric = rows.every(row => { + const text = row.cells[colIndex]?.textContent?.trim() ?? ""; + return text !== "" && !isNaN(Number(text)); + }); + + const dir = next === ASC ? 1 : -1; + rows.sort((a, b) => { + const x = a.cells[colIndex]?.textContent?.trim() ?? ""; + const y = b.cells[colIndex]?.textContent?.trim() ?? ""; + const cmp = isNumeric ? Number(x) - Number(y) : x.localeCompare(y); + return dir * cmp; + }); + } + + /** @type {typeof tbody} */ + const newTbody = tbody.cloneNode(false); + for (const row of rows) newTbody.append(row); + tbody.replaceWith(newTbody); + }); +} diff --git a/src/styles/sortable-table.css.js b/src/styles/sortable-table.css.js new file mode 100644 index 0000000000..a7da1f6a55 --- /dev/null +++ b/src/styles/sortable-table.css.js @@ -0,0 +1,31 @@ +const css = String.raw; + +// prettier-ignore +export default css` + .sortable th { + vertical-align: middle; + white-space: nowrap; + } + + .sortable th button { + background: transparent; + color: inherit; + border: none; + font-size: 0.7em; + padding: 0 0.3em; + cursor: pointer; + opacity: 0.5; + vertical-align: middle; + } + + .sortable th button:hover, + .sortable th button:focus { + opacity: 1; + outline: 1px solid; + } + + .sortable th[aria-sort="ascending"] button, + .sortable th[aria-sort="descending"] button { + opacity: 1; + } +`; diff --git a/tests/spec/core/sortable-table-spec.js b/tests/spec/core/sortable-table-spec.js new file mode 100644 index 0000000000..984c9d8270 --- /dev/null +++ b/tests/spec/core/sortable-table-spec.js @@ -0,0 +1,214 @@ +"use strict"; + +import { flushIframes, makeRSDoc, makeStandardOps } from "../SpecHelper.js"; + +describe("Core — sortable-table", () => { + afterAll(flushIframes); + + /** + * @param {HTMLElement} el + */ + function click(el) { + el.dispatchEvent( + new MouseEvent("click", { bubbles: true, cancelable: true }) + ); + } + + /** + * The runtime defers setup via document.respec.ready.then(), so we must + * flush the iframe's pending microtasks before interacting with buttons. + * @param {Document} doc + */ + async function flushRuntime(doc) { + await new Promise(r => doc.defaultView.setTimeout(r, 0)); + } + + const sortableBody = ` +
+

Test Section

+ + + + + + + + + + + + +
NameScore
Charlie3
Alice1
Bob2
+
+ `; + + describe("module bootstrap", () => { + it("does not run when no sortable table is present", async () => { + const body = `
plain table
`; + const ops = makeStandardOps(null, body); + const doc = await makeRSDoc(ops); + const script = doc.getElementById("respec-sortable-table"); + expect(script).toBeNull(); + }); + + it("injects the runtime script when a sortable table exists", async () => { + const ops = makeStandardOps(null, sortableBody); + const doc = await makeRSDoc(ops); + const script = doc.getElementById("respec-sortable-table"); + expect(script).not.toBeNull(); + }); + + it("injects a