PageSourceSearch

https://flnk.it/_content/PSC.Blazor.Components.Projects/js/projects-table-resize.okpbvc7xgp.js

js flnk.it collected 2026-09-24 09:06:36 UTC 10,602 bytes, 247 lines download raw bytes

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.