doxa-maps/library/utils/scrollport.js
Doxa Maps 07eb74c33c Move four identical bodies into the library
From the fleet census: 620 function bodies indexed across 29 plugin trees,
16 identical in two or more. These are the four cheapest — none closes over
anything beyond its own parameters, so each is a file move plus an import
rather than a new dependency to thread.

hostElement resolves the custom element a component is mounted inside, one
hop out through the shadow root. It was identical in three plugins, and it
is the piece worth sharing for a reason other than line count: every plugin
ships through defineCustomElement, a composite nests two shadow roots, and
code that forgets the hop operates on the wrong node while looking correct.
A document-level query for a node inside a shadow root returns null on a
working build, which reads as a broken feature rather than a wrong query.
Three copies were three chances to write that.

findScrollportAncestor walks up to the nearest ancestor that actually
scrolls, crossing shadow boundaries on the way — parentElement stops at a
root, so a plugin in a custom element would otherwise find no scrollport and
conclude the page does not scroll.

findNodeInTree and buildFlatLegendMatchExpr join useLegendMapBridge, which
already holds four functions from the same seam. They sit at module level
rather than inside the factory because they close over nothing: a pure
function hidden in a closure can only be reached by constructing the whole
bridge.

Call sites keep their old names through a one-line local binding, so no
call changed and the diff is the declaration plus an import.

Rebuild-and-compare, all five affected bundles, before -> after:
  regions-dashboard   1,098,469 -> 1,098,479   +10
  country-posters     1,484,053 -> 1,484,028   -25
  doxa-research-map   1,354,051 -> 1,354,061   +10
  dashboard-plus-grid   915,660 ->   915,644   -16
  big-picture           366,907 ->   366,719  -188
  net -209, and country-posters gains 25 bytes against the 1.5 MB ceiling.
Every behaviour-bearing token counted, not set-compared, and unchanged in
all three map bundles: regions-fill, peoplePraying, engagementStatus,
adoptionStatus, overflowY, scrollHeight, getRootNode, _flat_filter, religion.

Owners cleared their own files first: regions for regions-list.vue, upg100
for countries-map.vue. check:selection-kinds, check:legend-matches-source,
check:legend-data-contract, check:packet-variants, check:no-dead-library and
check:no-cross-plugin-imports all pass.
2026-09-29 09:19:04 +00:00

33 lines
1.5 KiB
JavaScript

/**
* findScrollportAncestor — the nearest ancestor that actually scrolls.
*
* Used to decide where a map should hand the wheel back to the page, and to scroll a selected row
* into view without moving the whole document.
*
* ⚠ THE WALK CROSSES SHADOW BOUNDARIES, and that is the whole reason this is not a two-line
* `closest()`. `parentElement` stops at a shadow root, so a plugin mounted in a custom element would
* find no scrollport at all and conclude the page does not scroll — the ladder therefore steps
* `parentElement` → `parentNode.host` → `parentNode`, which is what carries it out of one shadow
* tree and into the next. A composite nests two.
*
* ⚠ "SCROLLS" MEANS BOTH: overflow allows it AND there is something to scroll. An element with
* `overflow-y: auto` and no excess content is not a scrollport, and treating it as one stops the
* search at the wrong node. The +4 tolerance keeps sub-pixel layout rounding from reading as scrollable.
*
* @param {Node} el the node to search upward from
* @returns {Element|null}
*/
export function findScrollportAncestor(el) {
let node = el && el.parentNode
while (node) {
if (node.nodeType === 1) {
const cs = getComputedStyle(node)
if (/auto|scroll/.test(cs.overflowY) && node.scrollHeight > node.clientHeight + 4) return node
}
node = node.parentElement || node.parentNode?.host || (node.parentNode !== node ? node.parentNode : null)
if (node && node.nodeType === 9) break
}
return null
}
export default findScrollportAncestor