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