diff options
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/static/sticky.js')
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/static/sticky.js | 84 |
1 files changed, 84 insertions, 0 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/sticky.js b/packages/meshbay-hub/src/meshbay_hub/static/sticky.js new file mode 100644 index 0000000..e48d88d --- /dev/null +++ b/packages/meshbay-hub/src/meshbay_hub/static/sticky.js @@ -0,0 +1,84 @@ +import { useRef, useCallback } from './vendor/htm-preact.js'; + +// ── Sticky chrome ──────────────────────────────────────────────────────────── +// +// Files, Videos, Music and Photos are read by scrolling, and everything that +// steers that reading — which application, where in the tree, which filter — +// used to scroll away with the first screenful. The controls stay pinned under +// the navigation bar instead: the group's tab bar (or, on the Search page, the +// search field and its view toggle), then the application's own toolbar, then +// the file table's column heads. `style.css`'s "Sticky chrome" section holds +// the rules; this holds the one thing CSS cannot supply. +// +// A band's `top` is the sum of the heights of the bands above it, and those +// heights are not constants: the toolbar wraps to two or three rows on a phone +// (`.video-toolbar { flex-wrap: wrap }` is deliberate), grows a field while a +// folder is being named, and loses its filter entirely under `hideFilter`. +// Writing a number down would be the second subtraction in a second file that +// CLAUDE.md already records twice — a page permanently a few pixels wrong, and +// nothing in either file to show it. So each band measures itself and publishes +// its height as a custom property; the stylesheet does the arithmetic in +// `calc()`, from the one measurement. +// +// This is not the mutate-then-measure loop that made the chat panel re-enter +// itself 120 times a second. The property a band writes moves the `top` of a +// *different*, lower band and nothing else: `--chrome-h` is read only by the +// toolbars, `--toolbar-h` only by `.file-table th`. Neither can change the +// height of the element being observed, so the observer cannot wake itself. +// +// The property lands on the band's **parent**, which is the page's own root +// element and therefore an ancestor of every band under it. An application +// returns a fragment rather than a single element (all four do), so there is no +// per-application node to hang it on — but every one of those fragments is +// rendered into the same page root, which is also where the tab bar and the +// search bar sit. + +/** + * A ref for an element that pins under the navigation bar and publishes its + * own height as `name` for whatever pins under *it*. + * + * Returns a ref callback rather than taking a `useRef` object because these + * bands are rendered conditionally — the toolbar exists only once the node has + * answered — and an effect keyed on a ref would not run when the element + * finally appears. A ref callback is invoked when it does, and again with + * `null` when it goes, which is also where the property is withdrawn: a stale + * `--toolbar-h` left behind by Files would offset a table that is no longer + * on the page. + */ +export function useStickyBand(name) { + const attached = useRef(null); + + return useCallback((el) => { + const prev = attached.current; + if (prev) { + if (prev.observer) prev.observer.disconnect(); + prev.host.style.removeProperty(name); + attached.current = null; + } + // `parentElement` is null for the brief moment a ref is applied to an + // element not yet inserted; there is nothing to publish onto then, and the + // next mount calls this again. + if (!el || !el.parentElement) return; + + const host = el.parentElement; + // Height **plus the band's own bottom margin**. What the band below needs + // is not where this one ends but where it ends *including the gap it keeps + // in the flow* — pinning absorbs that margin, and a band pinned flat + // against the one above it is what the first version of this shipped. + // The same number paints the gap (style.css's `--band-margin`), so the two + // cannot drift. + const publish = () => { + const gap = parseFloat(getComputedStyle(el).marginBottom) || 0; + host.style.setProperty(name, `${el.offsetHeight + gap}px`); + }; + publish(); + + // Older engines without ResizeObserver keep the height measured at mount, + // which is right until the toolbar wraps. The band is still pinned; only + // the one below it can end up a row too high. + const observer = typeof ResizeObserver === 'undefined' + ? null : new ResizeObserver(publish); + if (observer) observer.observe(el); + attached.current = { host, observer }; + }, [name]); +} |