My theme for forester (+plugins)
1class TsvTable extends HTMLElement {
2 static get observedAttributes() {
3 return ["src"];
4 }
5
6 constructor() {
7 super();
8 this.attachShadow({ mode: "open" });
9
10 // Styles
11 const style = document.createElement("style");
12 style.textContent = `
13 :host {
14 display: block;
15 font-family: sans-serif;
16 }
17 input[type="search"] {
18 margin-bottom: 8px;
19 padding: 4px 6px;
20 width: 100%;
21 box-sizing: border-box;
22 font-size: 14px;
23 }
24 table {
25 border-collapse: collapse;
26 width: 100%;
27 }
28 th, td {
29 padding: 4px 8px;
30 border: 1px solid #ccc;
31 }
32 th {
33 cursor: pointer;
34 background: #f5f5f5;
35 user-select: none;
36 }
37 .sort-btn {
38 cursor: pointer;
39 font-size: 0.8em;
40 margin-left: 6px;
41 opacity: 0.6;
42 }
43 .sort-btn:hover {
44 opacity: 1;
45 }
46 .no-results {
47 padding: 8px;
48 color: #777;
49 font-style: italic;
50 }
51 `;
52 this.shadowRoot.appendChild(style);
53
54 // Search box
55 this.searchInput = document.createElement("input");
56 this.searchInput.type = "search";
57 this.searchInput.placeholder = "Search…";
58 this.searchInput.addEventListener("input", () => this.applyFilter());
59 this.shadowRoot.appendChild(this.searchInput);
60
61 // Table
62 this.table = document.createElement("table");
63 this.shadowRoot.appendChild(this.table);
64
65 // State
66 this.rows = []; // raw TSV data
67 this.filteredRows = []; // after search
68 this.sortState = {}; // column → "asc"/"desc"
69 }
70
71 connectedCallback() {
72 if (this.getAttribute("src")) {
73 this.loadTsv();
74 }
75 }
76
77 attributeChangedCallback(name, oldValue, newValue) {
78 if (name === "src" && newValue !== oldValue && this.isConnected) {
79 this.loadTsv();
80 }
81 }
82
83 async loadTsv() {
84 try {
85 const url = this.getAttribute("src");
86 const response = await fetch(url);
87 const text = await response.text();
88 this.rows = this.parseTsv(text);
89 this.filteredRows = this.rows; // no filter yet
90 this.renderTable(this.filteredRows);
91 } catch (err) {
92 console.error("TSV load error:", err);
93 this.table.innerHTML = "<tr><td>Error loading TSV</td></tr>";
94 }
95 }
96
97 parseTsv(text) {
98 return text
99 .trim()
100 .split("\n")
101 .map(row => row.split("\t").map(cell => cell.trim()));
102 }
103
104 applyFilter() {
105 const query = this.searchInput.value.toLowerCase().trim();
106
107 if (!query) {
108 this.filteredRows = this.rows;
109 } else {
110 const header = this.rows[0];
111 const body = this.rows.slice(1);
112
113 const filteredBody = body.filter(row =>
114 row.some(cell => cell.toLowerCase().includes(query))
115 );
116
117 this.filteredRows = [header, ...filteredBody];
118 }
119
120 this.renderTable(this.filteredRows);
121 }
122
123 sortByColumn(index) {
124 const header = this.filteredRows[0];
125 const body = this.filteredRows.slice(1);
126
127 // Toggle sort direction
128 const current = this.sortState[index] || "none";
129 const direction = current === "asc" ? "desc" : "asc";
130 this.sortState[index] = direction;
131
132 body.sort((a, b) => {
133 const A = a[index];
134 const B = b[index];
135
136 const numA = parseFloat(A);
137 const numB = parseFloat(B);
138
139 if (!isNaN(numA) && !isNaN(numB)) {
140 return direction === "asc" ? numA - numB : numB - numA;
141 }
142
143 return direction === "asc"
144 ? A.localeCompare(B)
145 : B.localeCompare(A);
146 });
147
148 this.filteredRows = [header, ...body];
149 this.renderTable(this.filteredRows);
150 }
151
152 renderTable(rows) {
153 if (!rows.length) {
154 this.table.innerHTML = "<tr><td class='no-results'>No results</td></tr>";
155 return;
156 }
157
158 const header = rows[0];
159 const body = rows.slice(1);
160
161 this.table.innerHTML = "";
162
163 // --- Header ---
164 const thead = document.createElement("thead");
165 const headerRow = document.createElement("tr");
166
167 header.forEach((h, index) => {
168 const th = document.createElement("th");
169
170 const label = document.createElement("span");
171 label.textContent = h;
172
173 const btn = document.createElement("span");
174 btn.textContent = "↕";
175 btn.className = "sort-btn";
176 btn.addEventListener("click", () => this.sortByColumn(index));
177
178 th.appendChild(label);
179 th.appendChild(btn);
180
181 headerRow.appendChild(th);
182 });
183
184 thead.appendChild(headerRow);
185 this.table.appendChild(thead);
186
187 // --- Body ---
188 const tbody = document.createElement("tbody");
189 body.forEach(row => {
190 const tr = document.createElement("tr");
191 row.forEach(cell => {
192 const td = document.createElement("td");
193 td.textContent = cell;
194 tr.appendChild(td);
195 });
196 tbody.appendChild(tr);
197 });
198
199 this.table.appendChild(tbody);
200 }
201}
202
203customElements.define("tsv-table", TsvTable);