1// projects-table-resize.js â generic column-resize behaviour for any 2// table that opts in by setting `id` + `data-resizable-id`. 3// 4// Usage from Blazor: 5// <table id="my-table" data-resizable-id="some-stable-key"> 6// <thead> 7// <tr> 8// <th>Col 1<span class="psc-tcr-handle"></span></th> 9// <th>Col 2<span class="psc-tcr-handle"></span></th> 10// <th>Col 3</th> <!-- last col: no handle --> 11// </tr> 12// </thead> 13// ... 14// </table> 15// 16// Caller imports this module on first render and calls init(tableId, 17// storageKey). Saved widths are stored in localStorage under 18// `psc-table-cols::{storageKey}` as a JSON array of pixel widths, 19// one per column. 20// 21// Why event delegation on the table (not direct on each handle): 22// Blazor's diff frequently re-creates thead / th elements when the 23// containing component re-renders (no stable @key on the row). Event 24// listeners attached to specific `<th>` elements would be lost on 25// every refresh. Delegation on the table element (which Blazor keeps 26// across renders because its id stays stable) survives reflows. 27// 28// Why span markers in markup vs JS-injected handles: same reason â 29// JS-injected DOM gets nuked when Blazor re-renders. Markup-rendered 30// handles persist because Blazor sees them as part of the diff 31// target's expected shape. 32 33const STORAGE_PREFIX = 'psc-table-cols::'; 34const STYLE_TAG_ID = 'psc-tcr-styles'; 35 36/** 37 * Inject the handle stylesheet once per page. The same CSS lives in 38 * wwwroot/css/projects.css for hosts that load that file, but injecting 39 * here as a failsafe means resize works even when the host hasn't 40 * wired up the project's stylesheet â saves a "why isn't this 41 * working?" support cycle. Idempotent via the style-tag id check. 42 */ 43function ensureStylesInjected() { 44 if (document.getElementById(STYLE_TAG_ID)) return; 45 const style = document.createElement('style'); 46 style.id = STYLE_TAG_ID; 47 style.textContent = ` 48 /* Fixed layout + width:100% anchor the table to its container, so 49 dragging one column shrinks the rest proportionally instead of 50 pushing the table â and the page â wider. Without this, browser 51 default 'table-layout: auto' treats style.width on <th> as a 52 minimum, not an absolute, and the table grows by the drag delta. 53 FLNKA-62. */ 54 table[data-resizable-id] { 55 table-layout: fixed; 56 width: 100%; 57 } 58 [data-bs-theme="dark"] table[data-resizable-id] { 59 --bs-table-bg: transparent; 60 } 61 [data-bs-theme="dark"] table[data-resizable-id] > :not(caption) > * > * { 62 background-color: transparent; 63 } 64 table[data-resizable-id] thead th { position: relative; } 65 table[data-resizable-id] thead th:last-child .psc-tcr-handle { display: none; } 66 .psc-tcr-handle { 67 position: absolute; top: 0; right: 0; bottom: 0; 68 width: 6px; cursor: col-resize; user-select: none; 69 background: transparent; transition: background 0.1s ease; z-index: 1; 70 } 71 .psc-tcr-handle::before { 72 content: ''; position: absolute; 73 top: 25%; bottom: 25%; right: 0; width: 1px; 74 background: var(--bs-border-color, #dee2e6); 75 transition: background 0.1s ease, top 0.1s ease, bottom 0.1s ease; 76 pointer-events: none; 77 } 78 .psc-tcr-handle:hover::before, .psc-tcr-handle.dragging::before { 79 background: var(--bs-primary, #0d6efd); top: 0; bottom: 0; 80 } 81 .psc-tcr-handle:hover, .psc-tcr-handle.dragging { 82 background: var(--bs-primary, #0d6efd); opacity: 0.55; 83 } 84 `; 85 document.head.appendChild(style); 86} 87 88export function init(tableId, storageKey) { 89 ensureStylesInjected(); 90 91 const table = document.getElementById(tableId); 92 if (!table) { 93 throw new Error(`projects-table-resize: table id '${tableId}' not in DOM.`); 94 } 95 96 // Re-applying saved widths is idempotent and cheap; do it on every 97 // init call (Blazor may call us multiple times across renders). The 98 // event-delegation listeners are bound only once via __pscTcrWired. 99 applySavedWidths(table, storageKey); 100 101 if (table.__pscTcrWired) return; 102 table.__pscTcrWired = true; 103 table.__pscTcrStorageKey = storageKey; 104 105 // Click-stopper for handles. When a header cell has its own
106 // onclick (e.g. sortable columns on the attachments table), 107 // dragging the handle could also trigger a sort because the 108 // synthetic click fires on mouseup. Run in capture phase on the 109 // table â by the time bubble-phase reaches the th, propagation 110 // has already been killed at the table level. preventDefault 111 // also stops the focus shift to the handle. 112 table.addEventListener('click', e => { 113 if (e.target && e.target.closest && e.target.closest('.psc-tcr-handle')) { 114 e.stopPropagation(); 115 e.preventDefault(); 116 } 117 }, true); 118 119 // Drag state â null when idle. Captured per drag, cleared on 120 // pointerup / pointercancel. Only one drag at a time per table. 121 let drag = null; 122 123 table.addEventListener('pointerdown', e => { 124 if (e.button !== 0) return; 125 const handle = e.target.closest('.psc-tcr-handle'); 126 if (!handle) return; 127 const th = handle.closest('th'); 128 if (!th) return; 129 130 e.preventDefault(); 131 // Capture so the drag continues even if the cursor leaves the 132 // handle (commonly: the user drags far past the column edge). 133 try { table.setPointerCapture(e.pointerId); } catch { /* noop */ } 134 handle.classList.add('dragging'); 135 136 // Neighbour-absorb: shrink the next column by the same delta the 137 // dragged column grows, so sum-of-widths stays constant and the 138 // table never overflows its container. Without this, saved widths 139 // from a prior session sum to more than 100% and the table widens 140 // even with `table-layout: fixed; width: 100%;` set in CSS â the 141 // fixed-layout algorithm honours each column's explicit width and 142 // grows the table to accommodate. FLNKA-62. 143 const nextTh = th.nextElementSibling && th.nextElementSibling.tagName === 'TH' 144 ? th.nextElementSibling 145 : null; 146 147 drag = { 148 pointerId: e.pointerId, 149 handle, 150 th, 151 nextTh, 152 startX: e.clientX, 153 startWidth: th.offsetWidth, 154 startNextWidth: nextTh ? nextTh.offsetWidth : 0, 155 }; 156 }); 157 158 // Minimum column width â applied to both the dragged column and the 159 // neighbour. Below this both columns become unreadable, and dragging 160 // past the neighbour's floor caps the dragged column's growth at the 161 // point the neighbour bottomed out. 162 const MIN_WIDTH = 40; 163 164 table.addEventListener('pointermove', e => { 165 if (!drag || e.pointerId !== drag.pointerId) return; 166 let delta = e.clientX - drag.startX; 167 168 // Clamp delta so neither column drops below MIN_WIDTH. Negative 169 // delta means dragging left â caps when this column hits MIN. 170 // Positive delta means dragging right â caps when the neighbour 171 // (if any) would shrink below MIN. 172 if (drag.nextTh) { 173 const minDelta = MIN_WIDTH - drag.startWidth; // <= 0 174 const maxDelta = drag.startNextWidth - MIN_WIDTH; // >= 0 175 delta = Math.max(minDelta, Math.min(maxDelta, delta)); 176 } else { 177 // Last-column-with-no-neighbour case: the resize handle on the 178 // very last column is hidden via CSS (`th:last-child .psc-tcr-handle`), 179 // so this branch only kicks in for tables where the layout has 180 // a weird shape and the handle is still visible. Fall back to 181 // the old behaviour (grow without shrinking anyone). 182 delta = Math.max(MIN_WIDTH - drag.startWidth, delta); 183 } 184 185 const newWidth = drag.startWidth + delta; 186 drag.th.style.width = newWidth + 'px'; 187 188 if (drag.nextTh) { 189 // Cap the neighbour at MIN_WIDTH so the visual matches what the 190 // clamp above already enforces (defensive â clamp should make 191 // this redundant, but a future change to clamp logic shouldn't 192 // be able to slip a sub-MIN width past us). 193 const newNextWidth = Math.max(MIN_WIDTH, drag.startNextWidth - delta); 194 drag.nextTh.style.width = newNextWidth + 'px'; 195 } 196 }); 197 198 function endDrag(e) { 199 if (!drag || e.pointerId !== drag.pointerId) return; 200 const d = drag; 201 drag = null; 202 d.handle.classList.remove('dragging'); 203 try { table.releasePointerCapture(d.pointerId); } catch { /* noop */ } 204 205 // Persist all column widths (not just the dragged one) so a 206 // partial restore on the next render doesn't drop the 207 // previously-saved sizes for the columns we didn't touch. 208 try { 209 const ths = Array.from(table.querySelectorAll('thead > tr:first-child > th')); 210 const widths = ths.map(t => t.offsetWidth);
211 const key = STORAGE_PREFIX + table.__pscTcrStorageKey; 212 localStorage.setItem(key, JSON.stringify(widths)); 213 } catch { /* localStorage may be unavailable in private mode */ } 214 } 215 table.addEventListener('pointerup', endDrag); 216 table.addEventListener('pointercancel', endDrag); 217} 218 219/** 220 * Apply saved widths to thead th elements. Called on every init so 221 * Blazor re-renders that swap in fresh th nodes still get the user's 222 * preferred sizes restored. No-op when no entry exists in storage. 223 */ 224function applySavedWidths(table, storageKey) { 225 if (!storageKey) return; 226 let saved = null; 227 try { 228 const raw = localStorage.getItem(STORAGE_PREFIX + storageKey); 229 if (raw) saved = JSON.parse(raw); 230 } catch { /* private mode / corrupted JSON */ } 231 if (!Array.isArray(saved)) return; 232 233 const ths = Array.from(table.querySelectorAll('thead > tr:first-child > th')); 234 saved.forEach((w, i) => { 235 if (typeof w === 'number' && w > 20 && ths[i]) { 236 ths[i].style.width = w + 'px'; 237 } 238 }); 239} 240 241/** 242 * Convenience: clear the saved widths for one storage key. Bound to 243 * the future "Reset column widths" admin / context-menu action. 244 */ 245export function reset(storageKey) { 246 try { localStorage.removeItem(STORAGE_PREFIX + storageKey); } catch { /* noop */ } 247}
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.