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..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 { @@ -555,18 +556,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); - // Return focus to whatever opened the modal. - if (CI.lastFocus && typeof CI.lastFocus.focus === 'function') CI.lastFocus.focus(); + if (updateUrl) { + const url = new URL(location.href);url.searchParams.delete('doi');history.replaceState(null,'',url); + } + // 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)); const TIMELINE_NARROW = 600; // Label style and collision spacing depend on the current chart width. @@ -731,11 +735,12 @@ // 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'); if (tabBtn && window.bootstrap) bootstrap.Tab.getOrCreateInstance(tabBtn).show(); - await showStudy(matchDoi); + await showStudy(matchDoi, true); return true; } 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..305e7534 100644 --- a/assets/view-controls.js +++ b/assets/view-controls.js @@ -1,68 +1,113 @@ /* 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'}; + // 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() { - 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; + 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. + 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(); + 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}); 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 +123,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..353ee299 100644 --- a/tests/browser.test.cjs +++ b/tests/browser.test.cjs @@ -578,3 +578,191 @@ 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.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)); + 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('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'); + 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(); +});