aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/static/sticky.js
blob: e48d88d9c13e87c25508a287719d6f8a14b00f51 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
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]);
}