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]); }