@lotics/ui 17.0.2 → 18.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -4,7 +4,13 @@ import { act, renderHook } from "@testing-library/react";
4
4
  import { useSectionNav } from "./use_section_nav";
5
5
  import type { LayoutChangeEvent, NativeScrollEvent, NativeSyntheticEvent } from "react-native";
6
6
 
7
- const layoutAt = (y: number) => ({ nativeEvent: { layout: { y } } }) as LayoutChangeEvent;
7
+ // RN-web's layout event carries the measured node as `nativeEvent.target`; RN's
8
+ // own (native-only) types don't model it, so it goes on after the cast.
9
+ const layoutAt = (y: number, target?: Element) => {
10
+ const e = { nativeEvent: { layout: { y } } } as LayoutChangeEvent;
11
+ if (target) Object.assign(e.nativeEvent, { target });
12
+ return e;
13
+ };
8
14
  const scrollTo = (y: number) => ({ nativeEvent: { contentOffset: { y } } }) as NativeSyntheticEvent<NativeScrollEvent>;
9
15
 
10
16
  function mounted() {
@@ -17,6 +23,68 @@ function mounted() {
17
23
  return hook;
18
24
  }
19
25
 
26
+ const rect = (top: number): DOMRect => ({
27
+ top,
28
+ bottom: top,
29
+ left: 0,
30
+ right: 0,
31
+ width: 0,
32
+ height: 0,
33
+ x: 0,
34
+ y: top,
35
+ toJSON: () => ({}),
36
+ });
37
+
38
+ /**
39
+ * A web page in miniature: a scroll container pinned at the viewport top plus
40
+ * section nodes sitting at live content offsets. `scrolledTo` moves the scroll
41
+ * position and every rect together (as a browser does), `shift` is the async
42
+ * content growth that moves sections WITHOUT an `onLayout` — the case a cached
43
+ * offset cannot see. A detached node measures as 0, like a real one.
44
+ */
45
+ function webPage<K extends string>(keys: readonly K[], contentY: Record<K, number>) {
46
+ let scrollY = 0;
47
+ let live: Record<K, number> = { ...contentY };
48
+ const node = document.createElement("div");
49
+ document.body.appendChild(node);
50
+ Object.defineProperty(node, "scrollTop", { get: () => scrollY });
51
+ node.getBoundingClientRect = () => rect(0);
52
+ const els = {} as Record<K, HTMLElement>;
53
+ for (const key of keys) {
54
+ const el = document.createElement("div");
55
+ document.body.appendChild(el);
56
+ el.getBoundingClientRect = () => (el.isConnected ? rect(live[key] - scrollY) : rect(0));
57
+ els[key] = el;
58
+ }
59
+ return {
60
+ node,
61
+ els,
62
+ shift: (next: Partial<Record<K, number>>) => {
63
+ live = { ...live, ...next };
64
+ },
65
+ scrolledTo: (y: number) => {
66
+ scrollY = y;
67
+ return scrollTo(y);
68
+ },
69
+ };
70
+ }
71
+
72
+ /** The registered page above, wired to a hook whose `scrollTo` calls are recorded. */
73
+ function webMounted(contentY: Record<"a" | "b" | "c", number>) {
74
+ const keys = ["a", "b", "c"] as const;
75
+ const page = webPage(keys, contentY);
76
+ const calls: { y: number }[] = [];
77
+ const hook = renderHook(() => useSectionNav(keys));
78
+ (hook.result.current.scrollRef as { current: unknown }).current = {
79
+ getScrollableNode: () => page.node,
80
+ scrollTo: (opts: { y: number }) => calls.push(opts),
81
+ };
82
+ act(() => {
83
+ for (const key of keys) hook.result.current.register(key)(layoutAt(contentY[key], page.els[key]));
84
+ });
85
+ return { result: hook.result, page, calls };
86
+ }
87
+
20
88
  describe("useSectionNav", () => {
21
89
  it("starts on the first key", () => {
22
90
  const { result } = renderHook(() => useSectionNav(["a", "b"] as const));
@@ -66,4 +134,38 @@ describe("useSectionNav", () => {
66
134
  act(() => result.current.jumpTo("a"));
67
135
  expect(calls[1]?.y).toBe(0);
68
136
  });
137
+
138
+ it("on web the highlight follows content that shifted after layout", () => {
139
+ const { result, page } = webMounted({ a: 0, b: 400, c: 900 });
140
+ act(() => result.current.onScroll(page.scrolledTo(321)));
141
+ expect(result.current.activeKey).toBe("b");
142
+
143
+ // a table above b finishes loading and pushes everything below it down 300px
144
+ page.shift({ b: 700, c: 1200 });
145
+ act(() => result.current.onScroll(page.scrolledTo(321)));
146
+ expect(result.current.activeKey).toBe("a");
147
+ act(() => result.current.onScroll(page.scrolledTo(700)));
148
+ expect(result.current.activeKey).toBe("b");
149
+
150
+ // and back: content above collapses, pulling the sections up again
151
+ page.shift({ b: 200, c: 500 });
152
+ act(() => result.current.onScroll(page.scrolledTo(450)));
153
+ expect(result.current.activeKey).toBe("c");
154
+ });
155
+
156
+ it("on web jumpTo lands on the section's shifted position", () => {
157
+ const { result, page, calls } = webMounted({ a: 0, b: 400, c: 900 });
158
+ page.shift({ b: 700 });
159
+ act(() => result.current.jumpTo("b"));
160
+ expect(calls[0]?.y).toBe(688);
161
+ });
162
+
163
+ it("a section removed from the DOM keeps its last offset instead of measuring as the page top", () => {
164
+ const { result, page } = webMounted({ a: 0, b: 400, c: 900 });
165
+ page.els.c.remove();
166
+ // 500 + 80 = 580: past b, still short of c — a detached c measured live
167
+ // would read as the current scroll position and steal the highlight.
168
+ act(() => result.current.onScroll(page.scrolledTo(500)));
169
+ expect(result.current.activeKey).toBe("b");
170
+ });
69
171
  });
@@ -4,7 +4,7 @@ import type { LayoutChangeEvent, NativeScrollEvent, NativeSyntheticEvent, Scroll
4
4
  export interface SectionNavHandle<K extends string> {
5
5
  /** Attach to the content `ScrollView` (with `onScroll` + `scrollEventThrottle={16}`). */
6
6
  scrollRef: RefObject<ScrollView | null>;
7
- /** The section the scroll currently sits in — drives the rail's `selected`. */
7
+ /** The section the scroll currently sits in — drives the rail item's `current`. */
8
8
  activeKey: K;
9
9
  /** `onLayout={register(key)}` on each section wrapper. The wrapper must be a
10
10
  * DIRECT child of the ScrollView content (layout.y is content-relative). */
@@ -21,28 +21,30 @@ export interface SectionNavHandle<K extends string> {
21
21
  * top has passed the viewport edge is the active one).
22
22
  *
23
23
  * const nav = useSectionNav(["details", "gatein", "gateout"] as const);
24
- * <MenuButton title="Gate in" selected={nav.activeKey === "gatein"}
24
+ * <MenuButton title="Gate in" current={nav.activeKey === "gatein"}
25
25
  * onPress={() => nav.jumpTo("gatein")} />
26
26
  * <ScrollView ref={nav.scrollRef} onScroll={nav.onScroll} scrollEventThrottle={16}>
27
27
  * <View onLayout={nav.register("gatein")}>…</View>
28
28
  *
29
+ * A rail item is the CURRENT item in a set of navigation links, so it takes
30
+ * `current` (→ `aria-current`), never `selected` (that is listbox selection).
31
+ *
29
32
  * Pass the keys in PAGE ORDER — the spy walks them top-down. Hide the rail on
30
33
  * narrow containers (the page still scrolls; the rail is a wide-screen aid).
31
34
  *
32
35
  * Contract: every key's section stays MOUNTED (an unmounted section leaves its
33
36
  * last offset registered — conditional sections belong inside an always-mounted
34
- * wrapper that carries the `onLayout`). On WEB, `jumpTo` re-measures the live
35
- * DOM position, so async content growing ABOVE a section (a photo grid, a
36
- * late-loading table) can never stale a jump; the cached offsets only drive
37
- * the scroll-spy highlight, which self-corrects as jumps refresh them. On
38
- * native there is no re-measure content above should settle before precision
39
- * matters (`onLayout` won't refire on a pure position shift).
37
+ * wrapper that carries the `onLayout`). On WEB both the jump AND the highlight
38
+ * read the live DOM position, so async content growing ABOVE a section (a photo
39
+ * grid, a late-loading table) can never stale either. On native there is no
40
+ * re-measure content above should settle before precision matters
41
+ * (`onLayout` won't refire on a pure position shift).
40
42
  */
41
43
  export function useSectionNav<K extends string>(keys: readonly [K, ...K[]]): SectionNavHandle<K> {
42
44
  const scrollRef = useRef<ScrollView>(null);
43
45
  const sectionY = useRef<Partial<Record<K, number>>>({});
44
46
  // The registered wrapper's DOM node (web only) — RN-web's layout event
45
- // exposes it as `nativeEvent.target`, which lets jumpTo re-measure live.
47
+ // exposes it as `nativeEvent.target`, which lets us re-measure live.
46
48
  const sectionEl = useRef<Partial<Record<K, Element>>>({});
47
49
  const [activeKey, setActiveKey] = useState<K>(keys[0]);
48
50
  const register = (key: K) => (e: LayoutChangeEvent) => {
@@ -50,27 +52,52 @@ export function useSectionNav<K extends string>(keys: readonly [K, ...K[]]): Sec
50
52
  const target: unknown = (e.nativeEvent as { target?: unknown }).target;
51
53
  if (typeof Element !== "undefined" && target instanceof Element) sectionEl.current[key] = target;
52
54
  };
53
- /** The section's CURRENT content-relative offset — measured live on web
54
- * (immune to async content shifting the page), cached `layout.y` otherwise. */
55
- const currentY = (key: K): number => {
56
- const el = sectionEl.current[key];
55
+ /**
56
+ * The sections' CURRENT content-relative offsets: refreshed from the live DOM
57
+ * on web, the cached `layout.y` otherwise. THE source of truth for both the
58
+ * jump and the highlight — a spy reading a different number than the jump is
59
+ * a spy that points at the wrong section.
60
+ *
61
+ * WHY measure on every scroll event rather than trust the cache: `onLayout`
62
+ * does not refire when a section merely MOVES, so any async shift above it (a
63
+ * table finishing load, a disclosure opening, an image settling) strands the
64
+ * cache and the rail highlights the wrong section until something re-measures.
65
+ *
66
+ * WHY that is not the per-frame layout thrash it looks like: reading a rect
67
+ * forces a reflow only while layout is DIRTY — which is exactly the case where
68
+ * the cache would have lied. A pure scroll dirties no layout, so these reads
69
+ * hit a clean tree and cost about a property access. The pass only READS
70
+ * (container once, then each section), so a dirty frame costs ONE flush for
71
+ * all sections, not one per section. Coalescing behind rAF was rejected: it
72
+ * cannot beat "free when nothing changed", and it would make the highlight
73
+ * lag the scroll by a frame while adding a cancel-on-unmount lifecycle.
74
+ */
75
+ const measure = (): Partial<Record<K, number>> => {
57
76
  const sv = scrollRef.current as (ScrollView & { getScrollableNode?: () => unknown }) | null;
58
77
  const sc = typeof sv?.getScrollableNode === "function" ? sv.getScrollableNode() : null;
59
- if (typeof Element !== "undefined" && el instanceof Element && sc instanceof Element) {
60
- const y = el.getBoundingClientRect().top - sc.getBoundingClientRect().top + sc.scrollTop;
61
- sectionY.current[key] = y; // refresh the spy's cache while we're here
62
- return y;
78
+ if (typeof Element === "undefined" || !(sc instanceof Element)) return sectionY.current;
79
+ // The content origin in viewport coordinates; a section's top minus it is
80
+ // the content-relative offset both `scrollTo` and the spy speak in.
81
+ const origin = sc.getBoundingClientRect().top - sc.scrollTop;
82
+ for (const k of keys) {
83
+ const el = sectionEl.current[k];
84
+ // A detached node measures as 0, which would pin the spy to the last
85
+ // section — a section that broke the mounted contract keeps its last
86
+ // known offset instead, exactly as it does on native.
87
+ if (el?.isConnected) sectionY.current[k] = el.getBoundingClientRect().top - origin;
63
88
  }
64
- return sectionY.current[key] ?? 0;
89
+ return sectionY.current;
65
90
  };
66
91
  const jumpTo = (key: K) => {
67
- scrollRef.current?.scrollTo({ y: Math.max(0, currentY(key) - 12), animated: true });
92
+ const y = measure()[key] ?? 0;
93
+ scrollRef.current?.scrollTo({ y: Math.max(0, y - 12), animated: true });
68
94
  };
69
95
  const onScroll = (e: NativeSyntheticEvent<NativeScrollEvent>) => {
70
96
  const y = e.nativeEvent.contentOffset.y + 80;
97
+ const offsets = measure();
71
98
  let cur: K = keys[0];
72
99
  for (const k of keys) {
73
- const sy = sectionY.current[k];
100
+ const sy = offsets[k];
74
101
  if (sy != null && sy <= y) cur = k;
75
102
  }
76
103
  if (cur !== activeKey) setActiveKey(cur);