@delacour/react-native-ui 0.3.0-alpha.20261017084646 → 0.3.0-alpha.20261017120102

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@delacour/react-native-ui",
3
- "version": "0.3.0-alpha.20261017084646",
3
+ "version": "0.3.0-alpha.20261017120102",
4
4
  "description": "React Native UI components — Uniwind, Reanimated, Gesture Handler",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -93,6 +93,7 @@
93
93
  "./hooks/use-controllable-state": "./src/hooks/use-controllable-state.ts",
94
94
  "./hooks/use-keyboard-state-sync": "./src/hooks/use-keyboard-state-sync.tsx",
95
95
  "./hooks/use-navigation-theme": "./src/hooks/use-navigation-theme.ts",
96
+ "./hooks/use-scroll-sections": "./src/hooks/use-scroll-sections.ts",
96
97
  "./hooks/use-theme-color": "./src/hooks/use-theme-color.ts",
97
98
  "./lib/calm-motion": "./src/lib/calm-motion.ts",
98
99
  "./lib/cn": "./src/lib/cn.ts",
@@ -102,6 +103,7 @@
102
103
  "./lib/merge-props": "./src/lib/merge-props.ts",
103
104
  "./lib/navigation-theme": "./src/lib/navigation-theme.ts",
104
105
  "./lib/paging": "./src/lib/paging.ts",
106
+ "./lib/scroll-sections": "./src/lib/scroll-sections.ts",
105
107
  "./lib/slot": "./src/lib/slot.tsx",
106
108
  "./lib/tv": "./src/lib/tv.ts",
107
109
  "./icons/central": "./src/icons/central.ts"
@@ -0,0 +1,137 @@
1
+ import { useCallback, useRef, useState } from "react";
2
+ import type { LayoutChangeEvent } from "react-native";
3
+ import type Animated from "react-native-reanimated";
4
+ import {
5
+ type AnimatedRef,
6
+ type SharedValue,
7
+ scrollTo as scrollToWorklet,
8
+ useAnimatedRef,
9
+ useAnimatedScrollHandler,
10
+ useSharedValue,
11
+ } from "react-native-reanimated";
12
+ import { scheduleOnRN, scheduleOnUI } from "react-native-worklets";
13
+ import {
14
+ resolveActiveSection,
15
+ resolveScrollProgress,
16
+ resolveSectionScrollTarget,
17
+ type SectionOffset,
18
+ } from "../lib/scroll-sections";
19
+
20
+ export type UseScrollSectionsOptions = {
21
+ /** Points below the viewport's top where a section counts as entered. Default 0. */
22
+ readingLine?: number;
23
+ /** Whether `scrollTo` animates. Default true. */
24
+ isAnimated?: boolean;
25
+ /** Fires when the reader crosses into a different section. */
26
+ onActiveChange?: (id: string) => void;
27
+ };
28
+
29
+ export type UseScrollSectionsResult = {
30
+ /** Attach to the `Animated.ScrollView` whose sections are tracked. */
31
+ scrollRef: AnimatedRef<Animated.ScrollView>;
32
+ /** Pass as that scroll view's `onScroll`, with `scrollEventThrottle={16}`. */
33
+ scrollHandler: ReturnType<typeof useAnimatedScrollHandler>;
34
+ /** `onLayout` for a section's box — a direct child of the scroll content. Stable per id. */
35
+ register: (id: string) => (event: LayoutChangeEvent) => void;
36
+ /** The section being read. React state: changes only when the section does. */
37
+ activeId: string | undefined;
38
+ /** 0–1, how far down the content the reader is. Updated on the UI thread. */
39
+ progress: SharedValue<number>;
40
+ /** The raw scroll offset. */
41
+ scrollY: SharedValue<number>;
42
+ /** Scrolls a section's top onto the reading line and makes it active at once. */
43
+ scrollTo: (id: string) => void;
44
+ };
45
+
46
+ /**
47
+ * Tracks which section of a scroll view the reader is in, and how far down they are, on the
48
+ * UI thread. `SectionProgress` and `SectionRail` read it; any custom table of contents can too.
49
+ *
50
+ * The scroll handler resolves the active section every frame but crosses to React only when it
51
+ * changes, so a scroll costs no renders. A `scrollTo` pins the target as active until the scroll
52
+ * arrives there (or the reader drags), so a jump from the first section to the last does not
53
+ * flick the label through every section in between.
54
+ *
55
+ * Offsets come from each section's `onLayout`, which reports `y` relative to the parent — so a
56
+ * registered section must be a direct child of the scroll content.
57
+ */
58
+ export function useScrollSections({
59
+ readingLine = 0,
60
+ isAnimated = true,
61
+ onActiveChange,
62
+ }: UseScrollSectionsOptions = {}): UseScrollSectionsResult {
63
+ const scrollRef = useAnimatedRef<Animated.ScrollView>();
64
+ const sections = useSharedValue<SectionOffset[]>([]);
65
+ const scrollY = useSharedValue(0);
66
+ const progress = useSharedValue(0);
67
+ const lastActive = useSharedValue<string | undefined>(undefined);
68
+ const pinned = useSharedValue<string | undefined>(undefined);
69
+ const [activeId, setActiveId] = useState<string | undefined>(undefined);
70
+
71
+ const report = useCallback(
72
+ (id: string) => {
73
+ setActiveId(id);
74
+ onActiveChange?.(id);
75
+ },
76
+ [onActiveChange]
77
+ );
78
+
79
+ const scrollHandler = useAnimatedScrollHandler({
80
+ onBeginDrag: () => {
81
+ pinned.value = undefined;
82
+ },
83
+ onScroll: (event) => {
84
+ const y = event.contentOffset.y;
85
+ scrollY.value = y;
86
+ progress.value = resolveScrollProgress(y, event.contentSize.height, event.layoutMeasurement.height);
87
+ const next = resolveActiveSection(sections.value, y, readingLine);
88
+ if (pinned.value !== undefined) {
89
+ if (next !== pinned.value) return;
90
+ pinned.value = undefined;
91
+ }
92
+ if (next !== undefined && next !== lastActive.value) {
93
+ lastActive.value = next;
94
+ scheduleOnRN(report, next);
95
+ }
96
+ },
97
+ });
98
+
99
+ const handlers = useRef(new Map<string, (event: LayoutChangeEvent) => void>());
100
+ const register = useCallback(
101
+ (id: string) => {
102
+ const cached = handlers.current.get(id);
103
+ if (cached) return cached;
104
+ const handler = (event: LayoutChangeEvent) => {
105
+ const y = event.nativeEvent.layout.y;
106
+ sections.modify((list) => {
107
+ "worklet";
108
+ const index = list.findIndex((section) => section.id === id);
109
+ if (index === -1) list.push({ id, y });
110
+ else list[index] = { id, y };
111
+ return list;
112
+ });
113
+ };
114
+ handlers.current.set(id, handler);
115
+ return handler;
116
+ },
117
+ [sections]
118
+ );
119
+
120
+ const scrollTo = useCallback(
121
+ (id: string) => {
122
+ const section = sections.value.find((candidate) => candidate.id === id);
123
+ if (section === undefined) return;
124
+ const target = resolveSectionScrollTarget(section.y, readingLine);
125
+ pinned.value = id;
126
+ lastActive.value = id;
127
+ report(id);
128
+ scheduleOnUI(() => {
129
+ "worklet";
130
+ scrollToWorklet(scrollRef, 0, target, isAnimated);
131
+ });
132
+ },
133
+ [isAnimated, lastActive, pinned, readingLine, report, scrollRef, sections]
134
+ );
135
+
136
+ return { scrollRef, scrollHandler, register, activeId, progress, scrollY, scrollTo };
137
+ }
@@ -0,0 +1,64 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { resolveActiveSection, resolveScrollProgress, resolveSectionScrollTarget } from "./scroll-sections";
3
+
4
+ const sections = [
5
+ { id: "intro", y: 0 },
6
+ { id: "setup", y: 400 },
7
+ { id: "usage", y: 1000 },
8
+ ];
9
+
10
+ describe("resolveActiveSection", () => {
11
+ test("returns undefined with no sections", () => {
12
+ expect(resolveActiveSection([], 100, 0)).toBeUndefined();
13
+ });
14
+
15
+ test("picks the last section whose top has crossed the reading line", () => {
16
+ expect(resolveActiveSection(sections, 0, 0)).toBe("intro");
17
+ expect(resolveActiveSection(sections, 399, 0)).toBe("intro");
18
+ expect(resolveActiveSection(sections, 400, 0)).toBe("setup");
19
+ expect(resolveActiveSection(sections, 2000, 0)).toBe("usage");
20
+ });
21
+
22
+ test("the reading line moves the switch earlier", () => {
23
+ expect(resolveActiveSection(sections, 304, 96)).toBe("setup");
24
+ expect(resolveActiveSection(sections, 303, 96)).toBe("intro");
25
+ });
26
+
27
+ test("above the first section reads as the first section", () => {
28
+ expect(resolveActiveSection([{ id: "a", y: 200 }], 0, 0)).toBe("a");
29
+ expect(resolveActiveSection(sections, -80, 0)).toBe("intro");
30
+ });
31
+
32
+ test("reads sections in offset order, not registration order", () => {
33
+ const shuffled = [sections[2], sections[0], sections[1]].filter((s) => s !== undefined);
34
+ expect(resolveActiveSection(shuffled, 500, 0)).toBe("setup");
35
+ });
36
+ });
37
+
38
+ describe("resolveScrollProgress", () => {
39
+ test("is 0 at the top and 1 at the end", () => {
40
+ expect(resolveScrollProgress(0, 2000, 800)).toBe(0);
41
+ expect(resolveScrollProgress(1200, 2000, 800)).toBe(1);
42
+ expect(resolveScrollProgress(600, 2000, 800)).toBe(0.5);
43
+ });
44
+
45
+ test("clamps overscroll at both ends", () => {
46
+ expect(resolveScrollProgress(-50, 2000, 800)).toBe(0);
47
+ expect(resolveScrollProgress(1500, 2000, 800)).toBe(1);
48
+ });
49
+
50
+ test("is 1 when nothing scrolls", () => {
51
+ expect(resolveScrollProgress(0, 500, 800)).toBe(1);
52
+ expect(resolveScrollProgress(0, 800, 800)).toBe(1);
53
+ });
54
+ });
55
+
56
+ describe("resolveSectionScrollTarget", () => {
57
+ test("puts the section's top on the reading line", () => {
58
+ expect(resolveSectionScrollTarget(400, 96)).toBe(304);
59
+ });
60
+
61
+ test("never scrolls above the top", () => {
62
+ expect(resolveSectionScrollTarget(40, 96)).toBe(0);
63
+ });
64
+ });
@@ -0,0 +1,44 @@
1
+ /** A section of a scrolled screen: its id and the top of its box in content coordinates. */
2
+ export type SectionOffset = { id: string; y: number };
3
+
4
+ /**
5
+ * The section the reader is in: the last one whose top has crossed the reading line, a fixed
6
+ * distance below the top of the viewport. Above the first section's top the first section is
7
+ * still the answer — a screen with a header before its first section is reading that section.
8
+ *
9
+ * Sections are read in offset order, not registration order: `onLayout` reports in whatever
10
+ * order the native views measure, and a section mounted late would otherwise win the tie.
11
+ */
12
+ export function resolveActiveSection(
13
+ sections: readonly SectionOffset[],
14
+ scrollY: number,
15
+ readingLine: number
16
+ ): string | undefined {
17
+ "worklet";
18
+ if (sections.length === 0) return undefined;
19
+ const line = scrollY + readingLine;
20
+ let first: SectionOffset | undefined;
21
+ let active: SectionOffset | undefined;
22
+ for (const section of sections) {
23
+ if (first === undefined || section.y < first.y) first = section;
24
+ if (section.y <= line && (active === undefined || section.y >= active.y)) active = section;
25
+ }
26
+ return (active ?? first)?.id;
27
+ }
28
+
29
+ /**
30
+ * How far down the content the reader is, 0 to 1. Overscroll clamps at both ends, and a screen
31
+ * whose content fits reads as finished — there is nothing left to read.
32
+ */
33
+ export function resolveScrollProgress(scrollY: number, contentHeight: number, viewportHeight: number): number {
34
+ "worklet";
35
+ const range = contentHeight - viewportHeight;
36
+ if (range <= 0) return 1;
37
+ return Math.min(1, Math.max(0, scrollY / range));
38
+ }
39
+
40
+ /** The scroll offset that puts a section's top on the reading line, never above the top. */
41
+ export function resolveSectionScrollTarget(y: number, readingLine: number): number {
42
+ "worklet";
43
+ return Math.max(0, y - readingLine);
44
+ }