aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/static/sticky.js
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/static/sticky.js')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/sticky.js84
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]);
+}