class TsvTable extends HTMLElement { static get observedAttributes() { return ["src"]; } constructor() { super(); this.attachShadow({ mode: "open" }); // Styles const style = document.createElement("style"); style.textContent = ` :host { display: block; font-family: sans-serif; } input[type="search"] { margin-bottom: 8px; padding: 4px 6px; width: 100%; box-sizing: border-box; font-size: 14px; } table { border-collapse: collapse; width: 100%; } th, td { padding: 4px 8px; border: 1px solid #ccc; } th { cursor: pointer; background: #f5f5f5; user-select: none; } .sort-btn { cursor: pointer; font-size: 0.8em; margin-left: 6px; opacity: 0.6; } .sort-btn:hover { opacity: 1; } .no-results { padding: 8px; color: #777; font-style: italic; } `; this.shadowRoot.appendChild(style); // Search box this.searchInput = document.createElement("input"); this.searchInput.type = "search"; this.searchInput.placeholder = "Search…"; this.searchInput.addEventListener("input", () => this.applyFilter()); this.shadowRoot.appendChild(this.searchInput); // Table this.table = document.createElement("table"); this.shadowRoot.appendChild(this.table); // State this.rows = []; // raw TSV data this.filteredRows = []; // after search this.sortState = {}; // column → "asc"/"desc" } connectedCallback() { if (this.getAttribute("src")) { this.loadTsv(); } } attributeChangedCallback(name, oldValue, newValue) { if (name === "src" && newValue !== oldValue && this.isConnected) { this.loadTsv(); } } async loadTsv() { try { const url = this.getAttribute("src"); const response = await fetch(url); const text = await response.text(); this.rows = this.parseTsv(text); this.filteredRows = this.rows; // no filter yet this.renderTable(this.filteredRows); } catch (err) { console.error("TSV load error:", err); this.table.innerHTML = "Error loading TSV"; } } parseTsv(text) { return text .trim() .split("\n") .map(row => row.split("\t").map(cell => cell.trim())); } applyFilter() { const query = this.searchInput.value.toLowerCase().trim(); if (!query) { this.filteredRows = this.rows; } else { const header = this.rows[0]; const body = this.rows.slice(1); const filteredBody = body.filter(row => row.some(cell => cell.toLowerCase().includes(query)) ); this.filteredRows = [header, ...filteredBody]; } this.renderTable(this.filteredRows); } sortByColumn(index) { const header = this.filteredRows[0]; const body = this.filteredRows.slice(1); // Toggle sort direction const current = this.sortState[index] || "none"; const direction = current === "asc" ? "desc" : "asc"; this.sortState[index] = direction; body.sort((a, b) => { const A = a[index]; const B = b[index]; const numA = parseFloat(A); const numB = parseFloat(B); if (!isNaN(numA) && !isNaN(numB)) { return direction === "asc" ? numA - numB : numB - numA; } return direction === "asc" ? A.localeCompare(B) : B.localeCompare(A); }); this.filteredRows = [header, ...body]; this.renderTable(this.filteredRows); } renderTable(rows) { if (!rows.length) { this.table.innerHTML = "No results"; return; } const header = rows[0]; const body = rows.slice(1); this.table.innerHTML = ""; // --- Header --- const thead = document.createElement("thead"); const headerRow = document.createElement("tr"); header.forEach((h, index) => { const th = document.createElement("th"); const label = document.createElement("span"); label.textContent = h; const btn = document.createElement("span"); btn.textContent = "↕"; btn.className = "sort-btn"; btn.addEventListener("click", () => this.sortByColumn(index)); th.appendChild(label); th.appendChild(btn); headerRow.appendChild(th); }); thead.appendChild(headerRow); this.table.appendChild(thead); // --- Body --- const tbody = document.createElement("tbody"); body.forEach(row => { const tr = document.createElement("tr"); row.forEach(cell => { const td = document.createElement("td"); td.textContent = cell; tr.appendChild(td); }); tbody.appendChild(tr); }); this.table.appendChild(tbody); } } customElements.define("tsv-table", TsvTable);