Pin hint: an inner scroller counts as a scrolling page, a map off the viewport is on one, and a stale lift is dropped
DX Life's /pray at 1440 kept the pill at the viewport's bottom until the map's bottom came into view: the page scrolls a container, not the document, so the document-only test saw a full-screen page and kept the fold. pageScrolls now also walks out from the pill, across shadow roots, for an ancestor scroller whose content is taller than it; a map wholly below or above the viewport is treated as on a scrolling page; and a lift is cleared when the pill goes off screen, so a lift set while the map was half visible does not survive into the next view. check:pin-hint-floor adds the inner-scroller, shadow-crossing and map-below-the-fold fixtures; its self-test refuses 9 planted defects.
This commit is contained in:
parent
c4d249dd0f
commit
7ea7195646
3 changed files with 64 additions and 8 deletions
|
|
@ -8,11 +8,15 @@
|
|||
* had its pill lifted to mid-map, re-set by the settle timers, and left there after a scroll.
|
||||
*
|
||||
* ASSERTS (headless, on library/components/hints/pinHintFloor.js and PinHint.vue's source):
|
||||
* 1. scrolling page, map bottom below the fold → no lift (the pill stays at the map's bottom);
|
||||
* 1. scrolling page, map bottom below the fold → no lift (the pill stays at the map's bottom); also a map
|
||||
* ENTIRELY below the fold, at load, with its bottom in view and scrolled past → no lift at any scroll;
|
||||
* 2. full-screen page, pill under the fold → lifted above it (today's behaviour kept);
|
||||
* 3. the legend sheet and the licence credit still lift the pill on a scrolling page;
|
||||
* 4. pageScrolls() tells a longer page from a full-screen one, and fails safe (false) on bad input;
|
||||
* 5. PinHint's clampIntoView takes its floor from pillFloor(), not from window.innerHeight.
|
||||
* 4b. an INNER scroller around the map (html/body at 100%) counts as a scrolling page, across shadow roots;
|
||||
* a scroller whose content fits does not;
|
||||
* 5. PinHint's clampIntoView takes its floor from pillFloor(), not from window.innerHeight, treats a map wholly
|
||||
* above or below the viewport as on a scrolling page, and drops a stale lift when the pill goes off screen.
|
||||
* --self-test plants each defect (the old fold-everywhere floor, a pageScrolls that never says yes, the
|
||||
* old source line) and must see each one refused.
|
||||
*/
|
||||
|
|
@ -33,6 +37,12 @@ function audit({ pillFloor, pageScrolls }, src) {
|
|||
const scrolls = pageScrolls(doc(3200), { innerHeight: 900 })
|
||||
const l1 = liftFor(1218, pillFloor({ viewportH: 900, scrolls }))
|
||||
if (l1 !== 0) bad.push(`a scrolling page lifts the pill ${l1}px off the map's bottom (the fold is not an obstacle there)`)
|
||||
// 1b. DX Life /pray at 1440: the map ENTIRELY below the fold (map 1103..1867, fold 900). The pill's bottom
|
||||
// (12px above the map's) must get NO lift from the fold at scrollY 0, with the map's bottom in view, or past it.
|
||||
for (const [scrollY, label] of [[0, 'at load'], [967, 'with the map bottom in view'], [2067, 'scrolled past']]) {
|
||||
const l = liftFor(1855 - scrollY, pillFloor({ viewportH: 900, scrolls }))
|
||||
if (l !== 0) bad.push(`a map below the fold lifts the pill ${l}px ${label} (scrollY ${scrollY})`)
|
||||
}
|
||||
// 2. A full-screen app page at 390×844: the pill 16px under the fold is lifted clear of it.
|
||||
const s2 = pageScrolls(doc(844), { innerHeight: 844 })
|
||||
const l2 = liftFor(860, pillFloor({ viewportH: 844, scrolls: s2 }))
|
||||
|
|
@ -42,6 +52,22 @@ function audit({ pillFloor, pageScrolls }, src) {
|
|||
if (l3 !== 76) bad.push(`the legend sheet lifts the pill ${l3}px on a scrolling page, expected 76`)
|
||||
const l3b = liftFor(1218, pillFloor({ viewportH: 900, scrolls: true, creditTop: 1200 }))
|
||||
if (l3b !== 26) bad.push(`the licence credit lifts the pill ${l3b}px on a scrolling page, expected 26`)
|
||||
// 4b. An INNER scroller: the document does not scroll (html/body at 100%), a container around the map does.
|
||||
const node = (oy, sh, ch, parent = null, host = null) => { const n = { nodeType: 1, oy, scrollHeight: sh, clientHeight: ch, parentElement: parent }; n.getRootNode = () => (host ? { host } : n); return n }
|
||||
const winStyle = { innerHeight: 900, getComputedStyle: (n) => ({ overflowY: n.oy }) }
|
||||
const scroller = node('auto', 3200, 900)
|
||||
const mapBox = node('hidden', 764, 764, scroller)
|
||||
const pillEl = node('visible', 19, 19, mapBox)
|
||||
if (pageScrolls(doc(900), winStyle, pillEl) !== true) bad.push('an inner scroller around the map is not seen as a scrolling page')
|
||||
const fits = node('auto', 900, 900); const pill2 = node('visible', 19, 19, node('hidden', 900, 900, fits))
|
||||
if (pageScrolls(doc(900), winStyle, pill2) !== false) bad.push('a scroller whose content fits is taken for a scrolling page')
|
||||
const outerScroller = node('scroll', 2400, 900)
|
||||
const shadowHost = node('visible', 900, 900, outerScroller)
|
||||
const inShadow = node('visible', 19, 19, null, shadowHost)
|
||||
if (pageScrolls(doc(900), winStyle, inShadow) !== true) bad.push('the walk does not cross a shadow root to the scroller outside it')
|
||||
// The inner-scroller page at 1440 with the map below the fold: lift 0 at every scroll.
|
||||
const s4 = pageScrolls(doc(900), winStyle, pillEl)
|
||||
for (const y of [0, 967, 2067]) { const l = liftFor(1855 - y, pillFloor({ viewportH: 900, scrolls: s4 })); if (l !== 0) bad.push(`an inner-scroller page lifts the pill ${l}px at scroll ${y}`) }
|
||||
// 4. pageScrolls on its own.
|
||||
if (pageScrolls(doc(900), { innerHeight: 900 }) !== false) bad.push('pageScrolls says a full-screen page scrolls')
|
||||
if (pageScrolls(doc(901), { innerHeight: 900 }) !== false) bad.push('pageScrolls counts a 1px rounding overflow as a scroll')
|
||||
|
|
@ -50,7 +76,9 @@ function audit({ pillFloor, pageScrolls }, src) {
|
|||
const body = (src.match(/function clampIntoView\(\)\s*\{([\s\S]*?)\n\}/) || [])[1] || ''
|
||||
if (!body) bad.push('PinHint.vue has no clampIntoView() to read')
|
||||
else {
|
||||
if (!/pillFloor\(\{[^}]*scrolls:\s*pageScrolls\(\)/.test(body)) bad.push('clampIntoView does not take its floor from pillFloor({ … scrolls: pageScrolls() … })')
|
||||
if (!/pillFloor\(\{[^}]*scrolls:\s*offPage \|\| pageScrolls\(document, window, el\)/.test(body)) bad.push('clampIntoView does not take its floor from pillFloor({ … scrolls: offPage || pageScrolls(document, window, el) … })')
|
||||
if (!/const offPage = !!hr && \(hr\.top >= window\.innerHeight \|\| hr\.bottom <= 0\)/.test(body)) bad.push('clampIntoView does not treat a map wholly above or below the viewport as on a scrolling page')
|
||||
if (!/if \(!onScreen\.value \|\| !paneActive\.value\) \{ el\.style\.removeProperty\('--pin-hint-lift'\); return \}/.test(body)) bad.push('clampIntoView keeps a stale lift when the pill goes off screen')
|
||||
if (/Math\.min\(\s*window\.innerHeight/.test(body)) bad.push('clampIntoView still floors on window.innerHeight directly')
|
||||
}
|
||||
return bad
|
||||
|
|
@ -66,6 +94,10 @@ if (process.argv.includes('--self-test')) {
|
|||
['a page that never scrolls', { ...real, pageScrolls: () => false }, SRC],
|
||||
['a page that always scrolls', { ...real, pageScrolls: () => true }, SRC],
|
||||
['obstacles dropped on a scrolling page', { ...real, pillFloor: (o) => (o.scrolls ? Infinity : F.pillFloor(o)) }, SRC],
|
||||
['an inner scroller ignored (document only)', { ...real, pageScrolls: (d, w) => F.pageScrolls(d, w) }, SRC],
|
||||
['no shadow-root crossing', { ...real, pageScrolls: (d, w, el) => { let e = el; while (e) { const oy = w && w.getComputedStyle ? w.getComputedStyle(e).overflowY : ''; if ((oy === 'auto' || oy === 'scroll') && e.scrollHeight > e.clientHeight + 1) return true; e = e.parentElement } return F.pageScrolls(d, w) } }, SRC],
|
||||
['a stale lift kept off screen', real, SRC.replace("if (!onScreen.value || !paneActive.value) { el.style.removeProperty('--pin-hint-lift'); return }", 'if (!onScreen.value || !paneActive.value) return')],
|
||||
['the off-page rule dropped', real, SRC.replace('scrolls: offPage || pageScrolls(document, window, el)', 'scrolls: pageScrolls(document, window, el)')],
|
||||
['the old source line', real, SRC.replace(/const floor = pillFloor\([^\n]*\n/, 'const floor = Math.min(window.innerHeight, sheetFloor(r), creditFloor(r))\n')],
|
||||
]
|
||||
let missed = 0
|
||||
|
|
|
|||
|
|
@ -281,7 +281,10 @@ function creditFloor(pill) {
|
|||
|
||||
function clampIntoView() {
|
||||
const el = hintEl.value
|
||||
if (!el || !onScreen.value || !paneActive.value) return
|
||||
if (!el) return
|
||||
// Off screen or in a hidden pane: drop any lift first. A lift set while the map was partly visible must not
|
||||
// survive into the next view; the clamp runs again when the pane or the map comes back.
|
||||
if (!onScreen.value || !paneActive.value) { el.style.removeProperty('--pin-hint-lift'); return }
|
||||
try {
|
||||
// ★ ADJUST `bottom`, NEVER `transform`. My first attempt set el.style.transform and cleared it
|
||||
// between measurements — both wrong here, and the diagnosis took two failed builds:
|
||||
|
|
@ -304,7 +307,11 @@ function clampIntoView() {
|
|||
// stayed at a stale 43px and the pill covered 36px of the 37px credit, while the same page, measured
|
||||
// later, sat at the cap. This check runs from the settle timers and the sheet and host observers,
|
||||
// so it reads where the credit IS, whatever the clearance says.
|
||||
const floor = pillFloor({ viewportH: window.innerHeight, scrolls: pageScrolls(), sheetTop: sheetFloor(r), creditTop: creditFloor(r) })
|
||||
// A map that starts below the fold or ends above the top cannot be a full-screen page: it is a block in a
|
||||
// page that scrolls, whatever scroller the page uses.
|
||||
const hr = el.parentElement && el.parentElement.getBoundingClientRect ? el.parentElement.getBoundingClientRect() : null
|
||||
const offPage = !!hr && (hr.top >= window.innerHeight || hr.bottom <= 0)
|
||||
const floor = pillFloor({ viewportH: window.innerHeight, scrolls: offPage || pageScrolls(document, window, el), sheetTop: sheetFloor(r), creditTop: creditFloor(r) })
|
||||
const overflow = Math.round(r.bottom - floor)
|
||||
if (overflow > 0) el.style.setProperty('--pin-hint-lift', (overflow + 8) + 'px')
|
||||
} catch (_) { /* detached — nothing to clamp */ }
|
||||
|
|
|
|||
|
|
@ -16,12 +16,29 @@
|
|||
* Pure (numbers in, a number out) so check:pin-hint-floor can prove both cases without a browser.
|
||||
*/
|
||||
|
||||
/** Does the page itself scroll (a map block in a longer page), as opposed to a full-screen app page? */
|
||||
/**
|
||||
* Does the map sit on a page that SCROLLS (a block in a longer page), as opposed to a full-screen app page?
|
||||
* Two ways a page scrolls: the document itself, or an INNER scroller around the map (a page that keeps html and
|
||||
* body at 100% and scrolls a container instead). The second is found by walking out from the pill (`fromEl`),
|
||||
* across shadow roots, for an ancestor with overflow-y auto/scroll/overlay whose content is taller than it.
|
||||
* On DX Life's /pray at 1440 the pill rode at the viewport's bottom until the map's own bottom came into view,
|
||||
* which is what a document-only test gives when the page scrolls an inner container.
|
||||
*/
|
||||
export function pageScrolls(doc = typeof document !== 'undefined' ? document : null,
|
||||
win = typeof window !== 'undefined' ? window : null) {
|
||||
win = typeof window !== 'undefined' ? window : null, fromEl = null) {
|
||||
try {
|
||||
const se = doc && (doc.scrollingElement || doc.documentElement)
|
||||
return !!(se && win && se.scrollHeight > win.innerHeight + 1)
|
||||
if (se && win && se.scrollHeight > win.innerHeight + 1) return true
|
||||
let el = fromEl
|
||||
for (let i = 0; el && i < 80; i++) {
|
||||
if (el.nodeType === 1 && win && typeof win.getComputedStyle === 'function') {
|
||||
const oy = win.getComputedStyle(el).overflowY
|
||||
if ((oy === 'auto' || oy === 'scroll' || oy === 'overlay') && el.scrollHeight > el.clientHeight + 1) return true
|
||||
}
|
||||
const root = typeof el.getRootNode === 'function' ? el.getRootNode() : null
|
||||
el = el.parentElement || (root && root !== el && root.host) || null
|
||||
}
|
||||
return false
|
||||
} catch (_) { return false }
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue