From 07f16697b5884136533ffb2b72ea7731374195ed Mon Sep 17 00:00:00 2001 From: Lukas Wallrich Date: Wed, 21 Oct 2026 19:27:08 +0100 Subject: [PATCH 1/2] Fix mobile chart scrolling and navigation state Disable Plotly zoom and pan in the shared renderer, allow study tables to scroll, and keep the citation dialog close action visible. Measure tab navigation overflow and switch to the existing select when needed. Consolidate URL writes so missing tab state restores Overview, history closes timelines, and shared links contain only active non-default controls. Keep legacy citation searches working and restore Browse pages. Add browser coverage for touch gestures, responsive tables and history. Co-Authored-By: GPT 6.1 Sol --- assets/app.js | 31 ++------ assets/chart-accessibility.js | 4 + assets/citation-impact.js | 8 +- assets/styles.css | 11 ++- assets/view-controls.js | 90 +++++++++++++++------- index.html | 2 +- tests/browser.test.cjs | 138 ++++++++++++++++++++++++++++++++++ 7 files changed, 226 insertions(+), 58 deletions(-) diff --git a/assets/app.js b/assets/app.js index 16149f7b..592b0218 100644 --- a/assets/app.js +++ b/assets/app.js @@ -773,6 +773,7 @@ function initDataTable(data) { createdRow: (row, d, dataIndex) => { $(row).attr('data-index', dataIndex); row.querySelector('.study-details').setAttribute('aria-label', 'Show evidence for ' + (data[dataIndex].title_o || 'original report')); } }); + dataTable.on('draw.dt', syncBrowseUrl); dataTable.on('order.dt', () => { if (bmInitialized) updateBrowseResults(false); syncBrowseUrl(); }); $('#flora-table tbody').on('click', 'button.study-details', function() { @@ -859,8 +860,8 @@ function setupBrowseMobile(data) { const next = document.getElementById('bm-next'); let debounceTimer; input.addEventListener('input', () => { clearTimeout(debounceTimer); debounceTimer = setTimeout(() => bmApplySearch(input.value), 200); }); - prev.addEventListener('click', () => { if (bmPage > 0) { bmPage--; bmRender(); document.querySelector('.app-main').scrollTo({ top: 0, behavior: 'smooth' }); } }); - next.addEventListener('click', () => { bmPage++; bmRender(); document.querySelector('.app-main').scrollTo({ top: 0, behavior: 'smooth' }); }); + prev.addEventListener('click', () => { if (bmPage > 0) { bmPage--; bmRender(); syncBrowseUrl(); document.querySelector('.app-main').scrollTo({ top: 0, behavior: 'smooth' }); } }); + next.addEventListener('click', () => { bmPage++; bmRender(); syncBrowseUrl(); document.querySelector('.app-main').scrollTo({ top: 0, behavior: 'smooth' }); }); input.value = browseQuery; bmApplySearch(browseQuery); } @@ -924,11 +925,7 @@ document.getElementById('bm-sort')?.addEventListener('change', e => { if (dataTable && direction) dataTable.order([[Number(column), direction]]).draw(); }); function syncBrowseUrl() { - const url = new URL(location.href); - browseQuery ? url.searchParams.set('q', browseQuery) : url.searchParams.delete('q'); - browseKind !== 'all' ? url.searchParams.set('kind', browseKind) : url.searchParams.delete('kind'); - if (dataTable) url.searchParams.set('sort', dataTable.order()[0].join(':')); - history.replaceState(null, '', url); + window.FloraView?.recordState(); } // ===== Browse kind filter ===== @@ -2158,28 +2155,10 @@ const TAB_ID_TO_PARAM = { // tab fires shown.bs.tab, which lazy-loads that tab's content as usual. function applyTabFromUrl() { const tab = (new URLSearchParams(window.location.search).get('tab') || '').toLowerCase(); - if (!tab) return; - const btn = document.getElementById(TAB_PARAM_MAP[tab] || ''); + const btn = document.getElementById(TAB_PARAM_MAP[tab] || 'overview-tab'); if (btn && window.bootstrap) bootstrap.Tab.getOrCreateInstance(btn).show(); } -// Reflect the active tab in the address bar so it stays shareable as the user -// navigates. Fires for both clicks and programmatic shows. -function syncTabToUrl(tabId) { - const name = TAB_ID_TO_PARAM[tabId]; - if (!name) return; - const params = new URLSearchParams(window.location.search); - if (name !== 'citations') params.delete('doi'); // doi only applies to Citation Impact - if (name === 'overview') params.delete('tab'); // keep the home URL clean - else params.set('tab', name); - const qs = params.toString(); - history.replaceState(null, '', new URL('./' + (qs ? '?' + qs : ''), window.location.href).href); -} -Object.keys(TAB_ID_TO_PARAM).forEach(id => { - const btn = document.getElementById(id); - if (btn) btn.addEventListener('shown.bs.tab', () => syncTabToUrl(id)); -}); - // Called directly (not via $(document).ready) so the CSV fetch starts as soon as this // script runs, rather than waiting for the whole document - including later, lazily-used // libraries like Plotly - to finish loading. Safe because this tag sits at the end of diff --git a/assets/chart-accessibility.js b/assets/chart-accessibility.js index 7ea4aa82..004783db 100644 --- a/assets/chart-accessibility.js +++ b/assets/chart-accessibility.js @@ -200,6 +200,10 @@ window.FloraCharts = (() => { const plotHeight = (layout.height || el.clientHeight || 380) - (layout.margin.t || 0) - layout.margin.b; layout.legend={...layout.legend,yanchor:'top',y:-axisSpace/Math.max(100,plotHeight)}; el.style.height = layout.height ? layout.height+'px' : heights.get(id); + // Charts keep hover details, while page swipes and drags never zoom or pan. + layout.dragmode = false; + for (const key of Object.keys(layout)) if (/^[xy]axis\d*$/.test(key)) layout[key] = {...layout[key],fixedrange:true}; + config = {...config,scrollZoom:false,doubleClick:false}; const title = layout.title ? plain(layout.title.text || layout.title) : visibleTitle(el); el.setAttribute('role','img');el.setAttribute('aria-label',title+'. Values in the following data table.'); if (comparisonIds.has(id)) { diff --git a/assets/citation-impact.js b/assets/citation-impact.js index f254bf61..ff331a58 100644 --- a/assets/citation-impact.js +++ b/assets/citation-impact.js @@ -555,18 +555,21 @@ document.querySelectorAll('body > .top-bar, body > main, body > .footer-bar').forEach(el => el.inert = value); } - function closeModal() { + function closeModal(updateUrl = true) { const modal = document.getElementById('ci-modal'); if (modal.hidden) return; modal.hidden = true; setBackgroundInert(false); CI.currentDoi = null; // Drop ?doi= but keep the user on the Citation Impact tab. - const url = new URL(location.href);url.searchParams.delete('doi');url.searchParams.set('tab','citations');history.replaceState(null,'',url); + if (updateUrl) { + const url = new URL(location.href);url.searchParams.delete('doi');history.replaceState(null,'',url); + } // Return focus to whatever opened the modal. if (CI.lastFocus && typeof CI.lastFocus.focus === 'function') CI.lastFocus.focus(); CI.lastFocus = null; } + document.addEventListener('flora-history', () => closeModal(false)); const TIMELINE_NARROW = 600; // Label style and collision spacing depend on the current chart width. @@ -731,6 +734,7 @@ // study's chart popup. Returns true if a matching study was found. async function openStudyByDoi(doi) { await ensureInit(); + if (normalizeDoi(new URLSearchParams(location.search).get('doi')) !== normalizeDoi(doi)) return false; const matchDoi = findStudyDoi(doi); if (!matchDoi) return false; const tabBtn = document.getElementById('citation-tab'); diff --git a/assets/styles.css b/assets/styles.css index c3d566ea..d86acbf7 100644 --- a/assets/styles.css +++ b/assets/styles.css @@ -447,7 +447,7 @@ tr.shown td.details-control .study-details::before { transform: rotate(90deg); } #search-input:focus { outline: none; border-color: var(--color-primary); } #filter-outcome, #sort-by { padding: 0.55rem 0.7rem; border: 1px solid var(--color-border); border-radius: 6px; font-size: 0.9rem; background: var(--color-surface-raised); color: var(--color-text); } -.table-wrap { background: var(--color-surface-raised); border: 1px solid var(--color-border); border-radius: 8px; overflow: hidden; } +.table-wrap { background: var(--color-surface-raised); border: 1px solid var(--color-border); border-radius: 8px; overflow-x: auto; } #originals-table { width: 100%; border-collapse: collapse; font-size: 0.92rem; } #originals-table thead { background: var(--color-surface-subtle); } #originals-table th, #originals-table td { padding: 0.75rem 1rem; text-align: left; border-bottom: 1px solid var(--color-border); } @@ -506,11 +506,13 @@ tr.shown td.details-control .study-details::before { transform: rotate(90deg); } .modal-backdrop[hidden] { display: none; } .modal-card { background: var(--color-surface-raised); color: var(--color-text); - border-radius: 10px; padding: 1.75rem 2rem; max-width: 1000px; + border-radius: 10px; padding: 0; max-width: 1000px; max-height: calc(100dvh - 80px); overflow-y: auto; width: 100%; position: relative; box-shadow: var(--shadow-lg); } +.modal-toolbar { position: sticky; top: 0; z-index: 2; display: flex; align-items: center; justify-content: space-between; gap: 1rem; padding: 0.5rem 1rem; background: var(--color-surface-raised); border-bottom: 1px solid var(--color-border); } +#ci-modal-body { padding: 0.75rem 1rem; } .modal-close { - position: absolute; top: 12px; right: 16px; background: none; border: none; + min-width: 44px; min-height: 44px; background: none; border: none; font-size: 1.75rem; cursor: pointer; color: var(--color-muted-fg); } .modal-close:hover { color: var(--color-text); } @@ -637,6 +639,9 @@ h1,.card-header,.mc-chart-title {font-family:var(--font-display);} .badge-other,.badge-unknown,.badge-inconclusive {background:#626875!important;color:#fff!important;} .bm-evidence {margin-top:.8rem;} .mobile-navigation {display:none;} +.top-bar.navigation-collapsed .mobile-navigation {display:flex;gap:.5rem;align-items:center;color:white;flex:1;min-width:0;} +.mobile-navigation select {max-width:100%;min-width:0;width:100%;padding:.4rem;background:#333;color:white;border:1px solid #aaa;border-radius:.3rem;} +.top-bar.navigation-collapsed .nav-tabs {display:none;} .coefficient-plot {min-height:340px;margin-top:1rem;} .tab-cite-block {margin:1rem 0;} .tab-cite-block:not([open]) {padding:.6rem 1rem;} diff --git a/assets/view-controls.js b/assets/view-controls.js index 1ba4be3c..40e0c925 100644 --- a/assets/view-controls.js +++ b/assets/view-controls.js @@ -1,68 +1,106 @@ /* Shareable filters, exports, and keyboard-visible navigation. */ (() => { - let restoring = false; + let restoring = false, ready = false; function pressed() { document.querySelectorAll('.chip, .trend-filter-btn').forEach(b => b.setAttribute('aria-pressed',String(b.classList.contains('active')))); } const initial = new URL(location.href); - const selectGroups = {'mc-study-type':'mc-kind','ao-study-type':'ao-kind','pubtype-study-type':'pub-kind','ci-study-type':'ci-kind','outcome-chips':'ci-outcome'}; + const selectGroups = {'mc-study-type':['mc-kind','mc-tab','replication'],'ao-study-type':['ao-kind','overlap-tab','replication'],'pubtype-study-type':['pub-kind','pub-tab','replication'],'ci-study-type':['ci-kind','citation-tab','replication'],'outcome-chips':['ci-outcome','citation-tab','all']}; + const controls = {'journal-top-n':['journal-top-n','trends-tab','15'],'rep-journal-top-n':['rep-journal-top-n','trends-tab','15'],'filter-outcome':['filter-outcome','citation-tab',''],'search-input':['ci-q','citation-tab','']}; function recordState() { - if (restoring) return; - const url = new URL(location.href); - for (const [id,key] of Object.entries(selectGroups)) { - const value = document.querySelector('#'+id+' .active')?.dataset.value; - if (value) url.searchParams.set(key,value); + if (restoring || !ready) return; + const url = new URL(location.href), tab = document.querySelector('#floraTabs .active').id; + const put = (key,value,fallback) => { if(String(value)!==String(fallback))url.searchParams.set(key,value); }; + // Remove known state first: a shared URL contains only the active view's choices. + for (const key of ['tab','q','kind','sort','page','page-size','mobile-page','trends-kind','search-input',...Object.values(selectGroups).map(v=>v[0]),...Object.values(controls).map(v=>v[0])]) url.searchParams.delete(key); + put('tab',TAB_ID_TO_PARAM[tab],'overview'); + if (tab !== 'citation-tab') url.searchParams.delete('doi'); + if (tab === 'browse-tab') { + put('q',browseQuery,'');put('kind',browseKind,'all'); + put('sort',dataTable.order()[0].join(':'),'4:desc'); + put('page',dataTable.page()+1,1);put('page-size',dataTable.page.len(),25); + put('mobile-page',bmPage+1,1); } - url.searchParams.set('trends-kind',trendsKind); - for (const id of ['journal-top-n','rep-journal-top-n','filter-outcome','search-input']) { - const el=document.getElementById(id);if(el?.value) url.searchParams.set(id,el.value);else url.searchParams.delete(id); + if (tab === 'trends-tab') put('trends-kind',trendsKind,'replication'); + for (const [id,[key,owner,fallback]] of Object.entries(selectGroups)) { + if(tab===owner) put(key,document.querySelector('#'+id+' .active')?.dataset.value || fallback,fallback); + } + for (const [id,[key,owner,fallback]] of Object.entries(controls)) { + if(tab===owner) put(key,document.getElementById(id).value,fallback); } history.replaceState(null,'',url); pressed(); } - function restore(url) { + window.FloraView = {recordState}; + function restore(url, fromHistory = false) { restoring = true; + if(fromHistory) { + document.dispatchEvent(new Event('flora-history')); + // Back/forward always dismisses a timeline, including its shareable DOI. + url.searchParams.delete('doi'); + } const kinds=['all','replication','reproduction-numerical','reproduction-robustness']; const kind=url.searchParams.get('kind') || 'all';browseKind=kinds.includes(kind)?kind:'all'; browseQuery=url.searchParams.get('q') || '';document.getElementById('browse-mobile-input').value=browseQuery; document.querySelectorAll('.browse-kind-btn').forEach(b=>b.classList.toggle('active',b.dataset.kind===browseKind)); const sort=url.searchParams.get('sort')?.split(':'); - if (sort && /^[1-8]$/.test(sort[0]) && ['asc','desc'].includes(sort[1])) dataTable.order([[Number(sort[0]),sort[1]]]); + dataTable.order(sort && /^[1-8]$/.test(sort[0]) && ['asc','desc'].includes(sort[1]) ? [[Number(sort[0]),sort[1]]] : [[4,'desc']]); + const length=Number(url.searchParams.get('page-size') || 25); + dataTable.page.len([10,25,50,100,-1].includes(length)?length:25); updateBrowseResults(); - for (const [id,key] of Object.entries(selectGroups)) { - const value=url.searchParams.get(key); - const btn=[...document.querySelectorAll('#'+id+' .chip')].find(b=>b.dataset.value===value); + const page=Number(url.searchParams.get('page') || 1); + dataTable.page(Number.isSafeInteger(page) && page>0 ? Math.min(page-1,Math.max(0,dataTable.page.info().pages-1)) : 0).draw('page'); + const mobilePage=Number(url.searchParams.get('mobile-page') || 1); + bmPage=Number.isSafeInteger(mobilePage) && mobilePage>0 ? mobilePage-1 : 0;bmRender(); + for (const [id,[key,,fallback]] of Object.entries(selectGroups)) { + const value=url.searchParams.get(key) || fallback; + const buttons=[...document.querySelectorAll('#'+id+' .chip')]; + const btn=buttons.find(b=>b.dataset.value===value) || buttons.find(b=>b.dataset.value===fallback); if(btn && !btn.classList.contains('active'))btn.click(); } const trend=url.searchParams.get('trends-kind') || 'replication'; - document.querySelectorAll('#trends .trend-filter-btn').forEach(b=>{ - if(b.dataset.kind===trend)b.click(); - }); - for (const id of ['journal-top-n','rep-journal-top-n','filter-outcome','search-input']) { - const el=document.getElementById(id);const value=url.searchParams.get(id); - if(el && value!==null){el.value=value;el.dispatchEvent(new Event(id==='search-input'?'input':'change'));} + const trendButtons=[...document.querySelectorAll('#trends .trend-filter-btn')]; + const trendButton=trendButtons.find(b=>b.dataset.kind===trend) || trendButtons.find(b=>b.dataset.kind==='replication'); + if(trendButton && !trendButton.classList.contains('active'))trendButton.click(); + for (const [id,[key,,fallback]] of Object.entries(controls)) { + const el=document.getElementById(id); + let value=url.searchParams.get(key) ?? (id==='search-input' ? url.searchParams.get('search-input') : null) ?? fallback; + if(el.tagName==='SELECT' && ![...el.options].some(o=>o.value===value))value=fallback; + if(el.value!==value){el.value=value;el.dispatchEvent(new Event(id==='search-input'?'input':'change'));} } history.replaceState(null,'',url); - applyTabFromUrl();pressed();restoring=false; + applyTabFromUrl();pressed();restoring=false;ready=true;recordState(); + document.getElementById('mobile-navigation').value=document.querySelector('#floraTabs .active').id; } document.addEventListener('flora-ready',()=>restore(initial),{once:true}); document.addEventListener('click',e=>{ if(e.target.closest('.chip, .trend-filter-btn')) {pressed();recordState();} }); - document.addEventListener('change',e=>{if(['journal-top-n','rep-journal-top-n','filter-outcome'].includes(e.target.id))recordState();}); + document.addEventListener('change',e=>{if(Object.keys(controls).includes(e.target.id))recordState();}); document.getElementById('search-input')?.addEventListener('input',recordState); document.getElementById('mobile-navigation').addEventListener('change',e=>bootstrap.Tab.getOrCreateInstance(document.getElementById(e.target.value)).show()); + const bar=document.querySelector('.top-bar'), tabs=document.getElementById('floraTabs'), mobile=document.querySelector('.mobile-navigation'); + function fitNavigation() { + // Measure the full strip with the select temporarily hidden, including after + // fonts load or the active label becomes bold. No viewport breakpoint needed. + tabs.style.display='flex';mobile.style.display='none'; + bar.classList.toggle('navigation-collapsed',tabs.scrollWidth>tabs.clientWidth+1); + tabs.style.display='';mobile.style.display=''; + } + new ResizeObserver(fitNavigation).observe(document.querySelector('.top-bar-inner')); + document.fonts.ready.then(fitNavigation);fitNavigation(); document.querySelectorAll('#floraTabs button').forEach(btn=>{ btn.addEventListener('show.bs.tab',()=>{ - if(!restoring && document.querySelector('#floraTabs .active')!==btn) history.pushState(null,'',location.href); + if(ready && !restoring && document.querySelector('#floraTabs .active')!==btn) history.pushState(null,'',location.href); }); btn.addEventListener('shown.bs.tab',()=>{ document.getElementById('mobile-navigation').value=btn.id; document.querySelector('.app-main').scrollTop = 0; + recordState();fitNavigation(); requestAnimationFrame(()=>window.dispatchEvent(new Event('resize'))); }); }); - window.addEventListener('popstate',()=>restore(new URL(location.href))); + window.addEventListener('popstate',()=>restore(new URL(location.href),true)); // In-page links to another tab switch tabs without reloading the data. // Modified or non-primary clicks keep normal link behaviour (new tab or window). document.addEventListener('click',e=>{ @@ -78,7 +116,7 @@ else if(pane){pane.setAttribute('tabindex','-1');pane.focus({preventScroll:true});} }); document.getElementById('share-view').onclick=async()=>{ - syncBrowseUrl();recordState(); + recordState(); try { await navigator.clipboard.writeText(location.href);document.getElementById('view-feedback').textContent='Link copied'; } catch { document.getElementById('view-feedback').textContent='Copy the current address from your browser.'; } }; diff --git a/index.html b/index.html index 0bee7b33..452b3d4e 100644 --- a/index.html +++ b/index.html @@ -1007,7 +1007,7 @@

Outcomes by report size

diff --git a/tests/browser.test.cjs b/tests/browser.test.cjs index 7b138022..989d252d 100644 --- a/tests/browser.test.cjs +++ b/tests/browser.test.cjs @@ -578,3 +578,141 @@ test('search input is debounced, Show all draws once and Citation Impact offers assert.ok(!new URL(p.url()).searchParams.has('search-input')); assert.deepEqual(p.errors,[]);await p.close(); }); + +test('overflowing navigation offers every tab and follows the active tab',async()=>{ + const p=await page('?tab=publication-type',1024); + assert.ok(await p.locator('#mobile-navigation').isVisible()); + assert.equal(await p.locator('#mobile-navigation').inputValue(),'pub-tab'); + for(const [option,pane] of [['browse-tab','browse'],['citation-tab','citation-impact'],['pub-tab','publication-type']]){ + await p.selectOption('#mobile-navigation',option); + await p.waitForFunction(id=>document.querySelector('.tab-pane.active').id===id,pane); + assert.equal(await p.locator('#mobile-navigation').inputValue(),option); + } + await p.setViewportSize({width:1800,height:900}); + await p.waitForFunction(()=>document.getElementById('floraTabs').getClientRects().length>0); + const bounds=await p.locator('#floraTabs').evaluate(el=>({width:el.clientWidth,content:el.scrollWidth})); + assert.ok(bounds.content<=bounds.width+1); + assert.ok(await p.locator('#pub-tab').isVisible()); + assert.deepEqual(p.errors,[]);await p.close(); +}); + +test('history restores absent defaults, active controls and closes citation dialogs',async()=>{ + const p=await page(''); + await p.click('#browse-tab');await p.fill('#browse-mobile-input','power posing'); + await p.waitForFunction(()=>location.search.includes('q=power')); + await p.goBack();await p.waitForFunction(()=>document.querySelector('.tab-pane.active').id==='overview'); + assert.equal(new URL(p.url()).search,''); + await p.goForward();await p.waitForFunction(()=>document.querySelector('.tab-pane.active').id==='browse'); + assert.equal(await p.locator('#browse-mobile-input').inputValue(),'power posing'); + assert.deepEqual([...new URL(p.url()).searchParams.keys()].sort(),['q','tab']); + await p.click('#citation-tab');await p.waitForSelector('.timeline-open'); + await p.locator('.timeline-open').first().click();await p.waitForSelector('#ci-modal:not([hidden])'); + await p.goBack();await p.waitForFunction(()=>document.querySelector('.tab-pane.active').id==='browse'); + assert.ok(await p.locator('#ci-modal').isHidden()); + assert.ok(await p.locator('.app-main').evaluate(el=>!el.inert)); + assert.equal(await p.locator('#browse-mobile-input').inputValue(),'power posing'); + // Forward may retain a timeline DOI in history, but always dismisses the dialog. + await p.goForward();await p.waitForFunction(()=>document.querySelector('.tab-pane.active').id==='citation-impact'); + assert.ok(await p.locator('#ci-modal').isHidden()); + assert.equal(new URL(p.url()).searchParams.has('q'),false); + assert.equal(new URL(p.url()).searchParams.has('doi'),false); + await p.click('#outcome-chips [data-value="failed"]'); + await p.fill('#search-input','Carney'); + await p.waitForFunction(()=>new URL(location.href).searchParams.get('ci-q')==='Carney'); + assert.deepEqual([...new URL(p.url()).searchParams.keys()].sort(),['ci-outcome','ci-q','tab']); + await p.click('#mc-tab');await p.goBack(); + await p.waitForFunction(()=>document.querySelector('.tab-pane.active').id==='citation-impact'); + assert.equal(await p.locator('#search-input').inputValue(),'Carney'); + assert.equal(await p.locator('#outcome-chips [data-value="failed"]').getAttribute('aria-pressed'),'true'); + // Missing values must reset controls, rather than retaining the previous entry's values. + await p.goBack();await p.goForward(); + await p.waitForFunction(()=>document.querySelector('.tab-pane.active').id==='citation-impact'); + await p.evaluate(()=>{history.pushState(null,'','?tab=citations');dispatchEvent(new PopStateEvent('popstate'));}); + assert.equal(await p.locator('#search-input').inputValue(),''); + assert.equal(await p.locator('#outcome-chips [data-value="all"]').getAttribute('aria-pressed'),'true'); + assert.deepEqual(p.errors,[]);await p.close(); +}); + +test('old shared citation links and Browse pagination restore with compact URLs',async()=>{ + const p=await page('?tab=citation-impact&search-input=Carney&ci-kind=replication&ci-outcome=all&mc-kind=replication&sort=4:desc'); + await p.waitForSelector('.timeline-open'); + assert.equal(await p.locator('#search-input').inputValue(),'Carney'); + assert.deepEqual([...new URL(p.url()).searchParams.entries()],[['tab','citations'],['ci-q','Carney']]); + assert.match(await p.locator('#originals-table tbody').innerText(),/Carney/); + await p.goto(base+'?tab=browse&page=2&page-size=10&sort=2:asc'); + await p.waitForFunction(()=>typeof dataTable!=='undefined'&&dataTable?.page.info().page===1); + assert.equal(await p.evaluate(()=>dataTable.page.len()),10); + assert.deepEqual(await p.evaluate(()=>dataTable.order()[0]),[2,'asc']); + await p.click('#mc-tab');await p.goBack(); + await p.waitForFunction(()=>document.querySelector('.tab-pane.active').id==='browse'); + assert.equal(await p.evaluate(()=>dataTable.page()),1);assert.equal(await p.evaluate(()=>dataTable.page.len()),10); + await p.evaluate(()=>dataTable.page('next').draw('page')); + await p.waitForFunction(()=>new URL(location.href).searchParams.get('page')==='3'); + await p.reload();await p.waitForFunction(()=>typeof dataTable!=='undefined'&&dataTable?.page()===2); + await p.setViewportSize({width:390,height:844}); + await p.click('#bm-next');await p.waitForFunction(()=>new URL(location.href).searchParams.get('mobile-page')==='2'); + await p.reload();await p.waitForFunction(()=>document.getElementById('bm-page-info').textContent.startsWith('Page 2 ')); + assert.deepEqual(p.errors,[]);await p.close(); +}); + +test('touch swipes scroll over every Plotly view while preserving chart ranges and hover',async()=>{ + const context=await browser.newContext({viewport:{width:390,height:844},hasTouch:true,isMobile:true}); + const p=await context.newPage();const errors=[];p.on('pageerror',e=>errors.push(e.message)); + await p.goto(base+'?tab=mean-citedness'); + await p.waitForFunction(()=>document.getElementById('mc-gam-chart')?.data); + const cdp=await context.newCDPSession(p); + async function swipe(selector,scroller){ + await p.locator(selector).scrollIntoViewIfNeeded(); + const box=await p.locator(selector).boundingBox(); + const y=Math.min(box.y+box.height-30,650),x=Math.min(box.x+box.width/2,350); + const before=await p.evaluate(({selector,scroller})=>({scroll:document.querySelector(scroller).scrollTop,ranges:Object.entries(document.querySelector(selector)._fullLayout).filter(([k])=>/^[xy]axis\d*$/.test(k)).map(([k,a])=>[k,a.range])}),{selector,scroller}); + await cdp.send('Input.dispatchTouchEvent',{type:'touchStart',touchPoints:[{x,y}]}); + for(let i=1;i<=8;i++){ + await cdp.send('Input.dispatchTouchEvent',{type:'touchMove',touchPoints:[{x,y:y-i*20}]});await p.waitForTimeout(25); + } + await cdp.send('Input.dispatchTouchEvent',{type:'touchEnd',touchPoints:[]}); + await p.waitForTimeout(250); + const after=await p.evaluate(({selector,scroller})=>({scroll:document.querySelector(scroller).scrollTop,ranges:Object.entries(document.querySelector(selector)._fullLayout).filter(([k])=>/^[xy]axis\d*$/.test(k)).map(([k,a])=>[k,a.range])}),{selector,scroller}); + assert.ok(after.scroll>before.scroll+30,`${selector} should scroll ${scroller}: ${before.scroll} → ${after.scroll}`); + assert.deepEqual(after.ranges,before.ranges,selector+' ranges'); + } + for(const [tab,ids] of [['mc-tab',['mc-dist-chart','mc-gam-chart']],['overlap-tab',['ao-chart']],['pub-tab',['pub-chart','rr-chart','ls-chart']],['citation-tab',['plot-cit','plot-cit-model','plot-cocit','plot-cocit-model']]]){ + await p.selectOption('#mobile-navigation',tab); + for(const id of ids){ + await p.waitForFunction(id=>document.getElementById(id)?.data,id); + const settings=await p.locator('#'+id).evaluate(el=>({dragmode:el._fullLayout.dragmode,axes:Object.entries(el._fullLayout).filter(([k])=>/^[xy]axis\d*$/.test(k)).every(([,a])=>a.fixedrange),scrollZoom:el._context.scrollZoom,doubleClick:el._context.doubleClick,hovermode:el._fullLayout.hovermode})); + assert.deepEqual(settings,{dragmode:false,axes:true,scrollZoom:false,doubleClick:false,hovermode:'closest'}); + await swipe('#'+id,'.app-main'); + } + } + await p.locator('.timeline-open').first().click();await p.waitForFunction(()=>document.getElementById('study-plot')?.data); + await swipe('#study-plot','.modal-card'); + await p.evaluate(()=>document.querySelector('.modal-card').scrollTop=10000); + const close=await p.locator('#ci-modal-close').boundingBox();assert.ok(close.width>=44&&close.height>=44&&close.y>=0&&close.y+close.height<844); + assert.deepEqual(errors,[]);await context.close(); + const desktop=await page('?tab=mean-citedness'); + await desktop.waitForFunction(()=>document.getElementById('mc-gam-chart')?.data); + await desktop.locator('#mc-gam-chart').scrollIntoViewIfNeeded(); + const point=await desktop.locator('#mc-gam-chart .scatterlayer .points path.point').first().boundingBox(); + await desktop.mouse.move(point.x+point.width/2,point.y+point.height/2); + await desktop.waitForSelector('#mc-gam-chart .hoverlayer .hovertext'); + assert.match(await desktop.locator('#mc-gam-chart .hoverlayer').textContent(),/OMC =/); + assert.deepEqual(desktop.errors,[]);await desktop.close(); +}); + +test('originals and Publication Type tables expose their last columns at phone and tablet widths',async()=>{ + const p=await page('?tab=citations',390);await p.waitForSelector('.timeline-open'); + async function reachable(id){ + const result=await p.locator('#'+id).evaluate(table=>{const wrap=table.closest('.table-wrap');wrap.scrollLeft=wrap.scrollWidth;const last=table.querySelector('tbody tr td:last-child').getBoundingClientRect(),bounds=wrap.getBoundingClientRect();return {overflow:getComputedStyle(wrap).overflowX,reachable:last.right<=bounds.right+1&&last.right>bounds.left,documentFits:document.documentElement.scrollWidth<=innerWidth};}); + assert.equal(result.overflow,'auto',id);assert.ok(result.reachable,id+' final column');assert.ok(result.documentFits,id+' document width'); + } + for(const width of [390,768]){ + await p.setViewportSize({width,height:844}); + await p.selectOption('#mobile-navigation','citation-tab');await reachable('originals-table'); + await p.selectOption('#mobile-navigation','pub-tab');await p.waitForFunction(()=>document.querySelector('#pub-studies-table tbody tr')); + for(const [details,table] of [['pub-studies-card','pub-studies-table'],['rr-studies-card','rr-studies-table'],['ls-studies-card','ls-studies-table']]){ + await p.locator('#'+details).evaluate(el=>el.open=true);await reachable(table); + } + } + assert.deepEqual(p.errors,[]);await p.close(); +}); From abeeaa05f7e3dcb56629c5bdc37dc98f018c8f06 Mon Sep 17 00:00:00 2001 From: Lukas Wallrich Date: Wed, 21 Oct 2026 20:13:30 +0100 Subject: [PATCH 2/2] Address review comments Keep DOI-only routes on Citation Impact during initial restoration so URL cleanup preserves pending deep links. Add a history entry for user-opened timelines while replacing initial deep links, and focus the restored pane instead of the opener on history-driven closes. Cover navigation, focus, delayed loading, and cancellation with browser regressions. Validation: TEST_PORT=9205 BROWSER_CHANNEL=chromium npm test (24 passed). Co-Authored-By: GPT 6.1 Sol --- assets/citation-impact.js | 13 +++++----- assets/view-controls.js | 7 ++++++ tests/browser.test.cjs | 50 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 64 insertions(+), 6 deletions(-) diff --git a/assets/citation-impact.js b/assets/citation-impact.js index ff331a58..009295e6 100644 --- a/assets/citation-impact.js +++ b/assets/citation-impact.js @@ -486,7 +486,7 @@ ${escapeHtml(entry.doi)}

`; } - async function showStudy(doi) { + async function showStudy(doi, fromDeepLink = false) { const entry = CI.index && CI.index.find(s => s.doi === doi); if (!entry) return; CI.currentDoi = doi; @@ -498,8 +498,9 @@ document.getElementById('ci-modal').hidden = false; setBackgroundInert(true); document.getElementById('ci-modal-close').focus(); - // Reflect the open chart in the address bar so it's directly shareable. - history.replaceState(null, '', citationLink(doi)); + // Add a closable history entry for user opens; keep initial share links in place. + if (fromDeepLink) history.replaceState(null, '', citationLink(doi)); + else history.pushState(null, '', citationLink(doi)); let s; try { @@ -565,8 +566,8 @@ if (updateUrl) { const url = new URL(location.href);url.searchParams.delete('doi');history.replaceState(null,'',url); } - // Return focus to whatever opened the modal. - if (CI.lastFocus && typeof CI.lastFocus.focus === 'function') CI.lastFocus.focus(); + // History restoration focuses the destination pane after switching tabs. + if (updateUrl && CI.lastFocus && typeof CI.lastFocus.focus === 'function') CI.lastFocus.focus(); CI.lastFocus = null; } document.addEventListener('flora-history', () => closeModal(false)); @@ -739,7 +740,7 @@ if (!matchDoi) return false; const tabBtn = document.getElementById('citation-tab'); if (tabBtn && window.bootstrap) bootstrap.Tab.getOrCreateInstance(tabBtn).show(); - await showStudy(matchDoi); + await showStudy(matchDoi, true); return true; } diff --git a/assets/view-controls.js b/assets/view-controls.js index 40e0c925..305e7534 100644 --- a/assets/view-controls.js +++ b/assets/view-controls.js @@ -5,6 +5,8 @@ document.querySelectorAll('.chip, .trend-filter-btn').forEach(b => b.setAttribute('aria-pressed',String(b.classList.contains('active')))); } const initial = new URL(location.href); + // A DOI-only share link targets Citation Impact even before its data arrives. + if (!initial.searchParams.has('tab') && initial.searchParams.get('doi')) initial.searchParams.set('tab','citations'); const selectGroups = {'mc-study-type':['mc-kind','mc-tab','replication'],'ao-study-type':['ao-kind','overlap-tab','replication'],'pubtype-study-type':['pub-kind','pub-tab','replication'],'ci-study-type':['ci-kind','citation-tab','replication'],'outcome-chips':['ci-outcome','citation-tab','all']}; const controls = {'journal-top-n':['journal-top-n','trends-tab','15'],'rep-journal-top-n':['rep-journal-top-n','trends-tab','15'],'filter-outcome':['filter-outcome','citation-tab',''],'search-input':['ci-q','citation-tab','']}; function recordState() { @@ -34,6 +36,7 @@ window.FloraView = {recordState}; function restore(url, fromHistory = false) { restoring = true; + const hadOpenTimeline = !document.getElementById('ci-modal').hidden; if(fromHistory) { document.dispatchEvent(new Event('flora-history')); // Back/forward always dismisses a timeline, including its shareable DOI. @@ -70,6 +73,10 @@ } history.replaceState(null,'',url); applyTabFromUrl();pressed();restoring=false;ready=true;recordState(); + if (fromHistory && hadOpenTimeline) { + const pane = document.querySelector('.tab-pane.active'); + pane.setAttribute('tabindex','-1');pane.focus({preventScroll:true}); + } document.getElementById('mobile-navigation').value=document.querySelector('#floraTabs .active').id; } document.addEventListener('flora-ready',()=>restore(initial),{once:true}); diff --git a/tests/browser.test.cjs b/tests/browser.test.cjs index 989d252d..353ee299 100644 --- a/tests/browser.test.cjs +++ b/tests/browser.test.cjs @@ -607,6 +607,9 @@ test('history restores absent defaults, active controls and closes citation dial assert.deepEqual([...new URL(p.url()).searchParams.keys()].sort(),['q','tab']); await p.click('#citation-tab');await p.waitForSelector('.timeline-open'); await p.locator('.timeline-open').first().click();await p.waitForSelector('#ci-modal:not([hidden])'); + await p.goBack();await p.waitForFunction(()=>document.getElementById('ci-modal').hidden); + assert.equal(await p.locator('.tab-pane.active').getAttribute('id'),'citation-impact'); + assert.equal(new URL(p.url()).searchParams.has('doi'),false); await p.goBack();await p.waitForFunction(()=>document.querySelector('.tab-pane.active').id==='browse'); assert.ok(await p.locator('#ci-modal').isHidden()); assert.ok(await p.locator('.app-main').evaluate(el=>!el.inert)); @@ -633,6 +636,53 @@ test('history restores absent defaults, active controls and closes citation dial assert.deepEqual(p.errors,[]);await p.close(); }); +test('history closing a timeline never leaves focus in the hidden citation pane',async()=>{ + const p=await page(''); + await p.click('#browse-tab');await p.click('#citation-tab');await p.waitForSelector('.timeline-open'); + await p.locator('.timeline-open').first().click();await p.waitForSelector('#ci-modal:not([hidden])'); + // Jump across the timeline and its parent tab, regardless of whether opening added an entry. + await p.evaluate(()=>{ + window.historyFocusedOpener=false; + document.addEventListener('focusin',e=>{if(e.target.matches('.timeline-open'))window.historyFocusedOpener=true;}); + }); + await p.evaluate(()=>history.go(-2)); + await p.waitForFunction(()=>document.getElementById('ci-modal').hidden && !document.getElementById('citation-impact').classList.contains('active')); + assert.equal(await p.evaluate(()=>window.historyFocusedOpener),false); + assert.ok(await p.evaluate(()=>document.activeElement.matches('.tab-pane.active'))); + assert.ok(await p.evaluate(()=>document.activeElement.getClientRects().length>0)); + assert.deepEqual(p.errors,[]);await p.close(); +}); + +test('bare DOI deep links survive initial restoration without adding a history entry',async()=>{ + const doi='10.1177/0956797610383437'; + let releaseIndex; + const indexGate=new Promise(resolve=>releaseIndex=resolve); + const p=await page('?doi='+encodeURIComponent(doi),1280,p=>p.route('**/data/originals_index.json',async route=>{await indexGate;await route.continue();})); + const initialHistory=await p.evaluate(()=>history.length); + releaseIndex(); + await p.waitForFunction(()=>document.getElementById('study-plot')?.data?.length>0,null,{timeout:10000}); + assert.equal(new URL(p.url()).searchParams.get('doi'),doi); + assert.equal(new URL(p.url()).searchParams.get('tab'),'citations'); + assert.equal(await p.evaluate(()=>history.length),initialHistory); + assert.deepEqual(p.errors,[]);await p.close(); +}); + +test('navigating away cancels a deep link while citation data is loading',async()=>{ + for(const query of ['?doi=10.1177%2F0956797610383437','?tab=citations&doi=10.1177%2F0956797610383437']){ + let releaseIndex; + const indexGate=new Promise(resolve=>releaseIndex=resolve); + const p=await page(query,1280,p=>p.route('**/data/originals_index.json',async route=>{await indexGate;await route.continue();})); + await p.click('#browse-tab');releaseIndex(); + await p.waitForFunction(()=>document.querySelector('.timeline-open')); + // Let the deep-link continuation run after the index has been rendered. + await p.evaluate(()=>new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)))); + assert.equal(await p.locator('.tab-pane.active').getAttribute('id'),'browse'); + assert.ok(await p.locator('#ci-modal').isHidden()); + assert.equal(new URL(p.url()).searchParams.has('doi'),false); + assert.deepEqual(p.errors,[]);await p.close(); + } +}); + test('old shared citation links and Browse pagination restore with compact URLs',async()=>{ const p=await page('?tab=citation-impact&search-input=Carney&ci-kind=replication&ci-outcome=all&mc-kind=replication&sort=4:desc'); await p.waitForSelector('.timeline-open');