@lotics/ui 14.2.0 → 14.3.1

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.
@@ -1,5 +1,5 @@
1
- import { useRef, type ReactNode } from "react";
2
- import { ScrollView, StyleProp, ViewStyle, type NativeScrollEvent, type NativeSyntheticEvent } from "react-native";
1
+ import { type ReactNode } from "react";
2
+ import { FlatList, StyleProp, View, ViewStyle } from "react-native";
3
3
 
4
4
  export interface FollowScrollProps {
5
5
  children: ReactNode;
@@ -9,50 +9,45 @@ export interface FollowScrollProps {
9
9
  contentContainerStyle?: StyleProp<ViewStyle>;
10
10
  }
11
11
 
12
- // "At the bottom" tolerance: within this many px of the end still counts as
13
- // pinned, so sub-pixel scroll positions and streaming-growth rounding never
14
- // silently unpin the follow.
15
- const PIN_THRESHOLD = 40;
12
+ const DATA = ["content"];
16
13
 
17
14
  /**
18
15
  * A scroll container that FOLLOWS its growing content — pinned to the bottom, it
19
16
  * keeps the newest content in view as children stream in (an `AgentRun` in a
20
- * dialog, a log feed). Scrolling up UNPINS it so the human can read history while
21
- * the content keeps growing; scrolling back to the bottom re-pins. Chat doesn't
22
- * need this (its message list is inverted, which follows by construction) — this
23
- * is for the plain top-down containers everywhere else: dialogs, drawers, panels.
17
+ * dialog, a log feed). Scrolling up releases the pin so the human can read
18
+ * history; scrolling back to the bottom re-pins.
19
+ *
20
+ * Implemented as a single-cell INVERTED list the same mechanism as a chat
21
+ * message list — so the follow happens at LAYOUT level: the browser paints each
22
+ * growth already pinned (no scroll-after-paint flash, no scroll math). Short
23
+ * content still reads top-down (the content is end-justified in inverted
24
+ * coordinates); once it overflows, the newest edge stays in view natively. The
25
+ * inverted pattern's quirks (scrollbar thumb runs opposite, keyboard paging
26
+ * inverted) match the chat surface users already know.
24
27
  */
25
28
  export function FollowScroll(props: FollowScrollProps) {
26
29
  const { children, style, contentContainerStyle } = props;
27
- const scrollRef = useRef<ScrollView>(null);
28
- // Pinned state lives in a ref — it changes on every scroll tick and must never
29
- // re-render the (streaming, already-busy) subtree.
30
- const pinnedRef = useRef(true);
31
-
32
- const followIfPinned = () => {
33
- if (pinnedRef.current) scrollRef.current?.scrollToEnd({ animated: false });
34
- };
35
-
36
- const onScroll = (e: NativeSyntheticEvent<NativeScrollEvent>) => {
37
- const { contentOffset, contentSize, layoutMeasurement } = e.nativeEvent;
38
- const distanceFromBottom = contentSize.height - contentOffset.y - layoutMeasurement.height;
39
- pinnedRef.current = distanceFromBottom < PIN_THRESHOLD;
40
- };
41
-
42
30
  return (
43
- <ScrollView
44
- ref={scrollRef}
31
+ <FlatList
32
+ inverted
33
+ data={DATA}
34
+ keyExtractor={(k) => k}
35
+ // The consumer's content styles live on an inner wrapper INSIDE the cell —
36
+ // the cell renders upright (double flip), so authored padding keeps its
37
+ // visual orientation; the flipped outer container must not carry it.
38
+ renderItem={() => <View style={contentContainerStyle}>{children}</View>}
39
+ // Children identity changes every stream tick; without extraData the list
40
+ // would keep rendering the mounted cell's stale content.
41
+ extraData={children}
45
42
  style={style}
46
- contentContainerStyle={contentContainerStyle}
47
- // Content grew (a new part streamed in) or the frame resized (dialog
48
- // re-measured) — both move the bottom edge, so both re-follow when pinned.
49
- onContentSizeChange={followIfPinned}
50
- onLayout={followIfPinned}
51
- onScroll={onScroll}
52
- scrollEventThrottle={16}
43
+ // flexGrow + flex-end top-aligns SHORT content (flex-end is the visual TOP
44
+ // in inverted coordinates), so a feed reads top-down until it overflows.
45
+ contentContainerStyle={followStyles.grow}
53
46
  nestedScrollEnabled
54
- >
55
- {children}
56
- </ScrollView>
47
+ />
57
48
  );
58
49
  }
50
+
51
+ const followStyles = {
52
+ grow: { flexGrow: 1, justifyContent: "flex-end" } as const,
53
+ };