diff --git a/public/app.js b/public/app.js index 6b177c89..84bfcc0b 100644 --- a/public/app.js +++ b/public/app.js @@ -2137,6 +2137,8 @@ function makeColumnsResizable(tableSelector, storageKey) { // Add resize handles ths.forEach((th, i) => { if (i === ths.length - 1) return; + // Anchor the handle to static headers without overriding sticky headers. + if (getComputedStyle(th).position === 'static') th.style.position = 'relative'; const handle = document.createElement('div'); handle.className = 'col-resize-handle'; handle.addEventListener('mousedown', (e) => { diff --git a/tests/e2e/test-table-fluid-e2e.js b/tests/e2e/test-table-fluid-e2e.js index 6795d030..189619f6 100644 --- a/tests/e2e/test-table-fluid-e2e.js +++ b/tests/e2e/test-table-fluid-e2e.js @@ -46,6 +46,13 @@ function expectHidden(pageName, w) { return vp ? vp.expectHidden : false; } +async function waitForAnalyticsTables(page) { + // The initial theme-refresh replaces the Nodes tab's tables. Wait for that + // lifecycle event and the handles on the resulting render before measuring. + await page.waitForFunction(() => window.__columnResizeThemeReady && + document.querySelector('#analyticsContent .analytics-table .col-resize-handle')); +} + (async () => { const browser = await chromium.launch({ headless: true, @@ -55,6 +62,66 @@ function expectHidden(pageName, w) { console.log(`\n=== #1056 fluid tables E2E against ${BASE} ===`); + for (const width of [1250, 375]) { + await step(`#2217: analytics resize handles stay on their headers at ${width}px`, async () => { + const ctx = await browser.newContext({ viewport: { width, height: 900 } }); + try { + await ctx.addInitScript(() => { + window.addEventListener('theme-refresh', () => { window.__columnResizeThemeReady = true; }, { once: true }); + }); + const page = await ctx.newPage(); + await page.goto(BASE + '/#/analytics?tab=nodes', { waitUntil: 'domcontentloaded' }); + await waitForAnalyticsTables(page); + const geometry = await page.evaluate(() => ({ + overflow: document.documentElement.scrollWidth - document.documentElement.clientWidth, + handles: Array.from(document.querySelectorAll('#analyticsContent .col-resize-handle')).map(handle => { + const header = handle.parentElement; + const h = header.getBoundingClientRect(), r = handle.getBoundingClientRect(); + return { anchored: handle.offsetParent === header, height: r.height, + top: r.top - h.top, edge: r.left + r.width / 2 - h.right }; + }), + })); + assert(geometry.handles.length > 0, 'expected analytics resize handles'); + assert(geometry.handles.every(h => h.anchored), 'resize handle has the page as its containing block'); + assert(geometry.handles.every(h => h.height > 0 && Math.abs(h.top) <= 2 && Math.abs(h.edge) <= 2), + 'resize handles must follow the header edge: ' + JSON.stringify(geometry.handles)); + assert(geometry.overflow <= 1, `analytics page overflows by ${geometry.overflow}px`); + + if (width === 1250) { + const selector = '#analyticsContent .analytics-table thead th:first-child'; + const header = page.locator(selector).first(); + await header.scrollIntoViewIfNeeded(); + const before = await header.evaluate(th => { + const r = th.getBoundingClientRect(); + return { width: th.offsetWidth, x: r.right - 2, y: r.top + r.height / 2 }; + }); + await page.mouse.move(before.x, before.y); + await page.mouse.down(); + await page.mouse.move(before.x + 40, before.y, { steps: 4 }); + await page.mouse.up(); + const dragged = await header.evaluate(th => ({ + width: th.offsetWidth, + percent: th.offsetWidth / th.closest('table').offsetWidth * 100, + saved: JSON.parse(localStorage.getItem('meshcore-analytics-nodes-0-col-widths')), + })); + assert(dragged.width > before.width + 20, 'dragging must resize the analytics column'); + assert(Array.isArray(dragged.saved), 'drag must save column widths'); + assert(Math.abs(dragged.saved[0] - dragged.percent) < 1, 'saved percentage must match dragged width'); + await page.reload({ waitUntil: 'domcontentloaded' }); + await waitForAnalyticsTables(page); + const restored = await header.evaluate(th => ({ + percent: th.offsetWidth / th.closest('table').offsetWidth * 100, + anchored: th.querySelector('.col-resize-handle').offsetParent === th, + })); + assert(Math.abs(restored.percent - dragged.saved[0]) < 1, 'reload must restore the saved column width'); + assert(restored.anchored, 'restored widths must still anchor the resize handle to its header'); + } + } finally { + await ctx.close(); + } + }); + } + await step('#2079: responsive columns preserve full-width status and spacer rows', async () => { const ctx = await browser.newContext({ viewport: { width: 375, height: 900 } }); try { @@ -134,6 +201,18 @@ function expectHidden(pageName, w) { assert(overflow.ok, `table overflows: tableW=${overflow.tableW} wrapW=${overflow.wrapW}`); }); + if (p.name === 'nodes') { + await step(`${tag}: resize handles preserve sticky headers (#2217)`, async () => { + const headers = await page.$$eval('#nodesTable th', ths => ths + .filter(th => th.offsetParent !== null && th.querySelector('.col-resize-handle')) + .map(th => ({ position: getComputedStyle(th).position, + anchored: th.querySelector('.col-resize-handle').offsetParent === th }))); + assert(headers.length > 0, 'expected visible Nodes resize handles'); + assert(headers.every(h => h.position === 'sticky' && h.anchored), + 'Nodes headers must remain sticky and contain their handles: ' + JSON.stringify(headers)); + }); + } + await step(`${tag}: +N hidden pill state matches hidden columns`, async () => { const info = await page.evaluate((sel) => { const t = document.querySelector(sel); diff --git a/tests/unit/test-frontend-helpers.js b/tests/unit/test-frontend-helpers.js index 240a05c2..35b71c0f 100644 --- a/tests/unit/test-frontend-helpers.js +++ b/tests/unit/test-frontend-helpers.js @@ -118,6 +118,50 @@ function realPathHashSize() { } // ===== APP.JS TESTS ===== +console.log('\n=== app.js: column resize handle positioning (#2217) ==='); +{ + function resizeFixture(positions, saved) { + const ctx = makeSandbox(); + loadInCtx(ctx, 'public/roles.js'); + loadInCtx(ctx, 'public/app.js'); + const ths = positions.map(position => ({ + style: { position: '' }, dataset: {}, scrollWidth: 80, + computedPosition: position, children: [], + appendChild(handle) { this.children.push(handle); }, + })); + const table = { + style: {}, dataset: {}, parentElement: { clientWidth: 400 }, + querySelector: sel => sel === 'thead' ? { querySelectorAll: () => ths } : null, + querySelectorAll: () => ths, + }; + ctx.document.querySelector = () => table; + ctx.document.createElement = () => ({ addEventListener: () => {} }); + ctx.getComputedStyle = th => ({ position: th.style.position || th.computedPosition }); + if (saved) ctx.localStorage.setItem('column-widths', JSON.stringify(ths.map(() => 100 / ths.length))); + return { ctx, table, ths }; + } + + for (const saved of [false, true]) { + test('static headers anchor handles with ' + (saved ? 'saved' : 'measured') + ' widths', () => { + const { ctx, ths } = resizeFixture(['static', 'static', 'static'], saved); + ctx.makeColumnsResizable('#table', 'column-widths'); + assert.strictEqual(ths[0].style.position, 'relative', 'first handle needs its own header as containing block'); + assert.strictEqual(ths[1].style.position, 'relative', 'each handle needs its own containing block'); + assert.strictEqual(ths[2].style.position, '', 'last header has no handle and needs no change'); + ctx.makeColumnsResizable('#table', 'column-widths'); + assert.deepStrictEqual(ths.map(th => th.children.length), [1, 1, 0], 'repeated setup must not duplicate handles'); + }); + + test('already positioned headers are preserved with ' + (saved ? 'saved' : 'measured') + ' widths', () => { + const { ctx, ths } = resizeFixture(['sticky', 'relative', 'absolute', 'fixed', 'static'], saved); + ctx.makeColumnsResizable('#table', 'column-widths'); + assert.deepStrictEqual(ths.map(th => th.style.position), ['', '', '', '', ''], + 'computed CSS positioning must not be overridden by an inline style'); + assert.deepStrictEqual(ths.map(th => th.children.length), [1, 1, 1, 1, 0]); + }); + } +} + console.log('\n=== app.js: timeAgo ==='); { const ctx = makeSandbox();