@oxy.so/bloom 7.4.0 → 7.6.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.
Files changed (117) hide show
  1. package/docs/adoption-matrix.mdx +5 -5
  2. package/docs/layout.mdx +80 -0
  3. package/docs/page-footer.mdx +13 -0
  4. package/docs/page-header.mdx +7 -0
  5. package/lib/commonjs/index.js +28 -0
  6. package/lib/commonjs/index.js.map +1 -1
  7. package/lib/commonjs/index.web.js +28 -0
  8. package/lib/commonjs/index.web.js.map +1 -1
  9. package/lib/commonjs/layout/ScrollArea.js +41 -0
  10. package/lib/commonjs/layout/ScrollArea.js.map +1 -0
  11. package/lib/commonjs/layout/index.js +28 -0
  12. package/lib/commonjs/layout/index.js.map +1 -1
  13. package/lib/commonjs/layout/screen-scope.js +6 -2
  14. package/lib/commonjs/layout/screen-scope.js.map +1 -1
  15. package/lib/commonjs/layout/scroll-metrics-events.js +19 -0
  16. package/lib/commonjs/layout/scroll-metrics-events.js.map +1 -0
  17. package/lib/commonjs/layout/scroll-metrics.js +50 -0
  18. package/lib/commonjs/layout/scroll-metrics.js.map +1 -0
  19. package/lib/commonjs/layout/scroll-offset.js +5 -1
  20. package/lib/commonjs/layout/scroll-offset.js.map +1 -1
  21. package/lib/commonjs/layout/use-animated-scroll-metrics-binding.js +50 -0
  22. package/lib/commonjs/layout/use-animated-scroll-metrics-binding.js.map +1 -0
  23. package/lib/commonjs/layout/use-scroll-metrics-binding.js +50 -0
  24. package/lib/commonjs/layout/use-scroll-metrics-binding.js.map +1 -0
  25. package/lib/commonjs/page-footer/PageFooter.js +10 -2
  26. package/lib/commonjs/page-footer/PageFooter.js.map +1 -1
  27. package/lib/commonjs/page-header/PageHeader.js +4 -2
  28. package/lib/commonjs/page-header/PageHeader.js.map +1 -1
  29. package/lib/module/index.js +1 -1
  30. package/lib/module/index.js.map +1 -1
  31. package/lib/module/index.web.js +1 -1
  32. package/lib/module/index.web.js.map +1 -1
  33. package/lib/module/layout/ScrollArea.js +36 -0
  34. package/lib/module/layout/ScrollArea.js.map +1 -0
  35. package/lib/module/layout/index.js +4 -0
  36. package/lib/module/layout/index.js.map +1 -1
  37. package/lib/module/layout/screen-scope.js +6 -2
  38. package/lib/module/layout/screen-scope.js.map +1 -1
  39. package/lib/module/layout/scroll-metrics-events.js +15 -0
  40. package/lib/module/layout/scroll-metrics-events.js.map +1 -0
  41. package/lib/module/layout/scroll-metrics.js +42 -0
  42. package/lib/module/layout/scroll-metrics.js.map +1 -0
  43. package/lib/module/layout/scroll-offset.js +5 -1
  44. package/lib/module/layout/scroll-offset.js.map +1 -1
  45. package/lib/module/layout/use-animated-scroll-metrics-binding.js +46 -0
  46. package/lib/module/layout/use-animated-scroll-metrics-binding.js.map +1 -0
  47. package/lib/module/layout/use-scroll-metrics-binding.js +47 -0
  48. package/lib/module/layout/use-scroll-metrics-binding.js.map +1 -0
  49. package/lib/module/page-footer/PageFooter.js +10 -2
  50. package/lib/module/page-footer/PageFooter.js.map +1 -1
  51. package/lib/module/page-header/PageHeader.js +4 -2
  52. package/lib/module/page-header/PageHeader.js.map +1 -1
  53. package/lib/typescript/commonjs/index.d.ts +2 -2
  54. package/lib/typescript/commonjs/index.d.ts.map +1 -1
  55. package/lib/typescript/commonjs/index.web.d.ts +2 -2
  56. package/lib/typescript/commonjs/index.web.d.ts.map +1 -1
  57. package/lib/typescript/commonjs/layout/ScrollArea.d.ts +11 -0
  58. package/lib/typescript/commonjs/layout/ScrollArea.d.ts.map +1 -0
  59. package/lib/typescript/commonjs/layout/index.d.ts +7 -0
  60. package/lib/typescript/commonjs/layout/index.d.ts.map +1 -1
  61. package/lib/typescript/commonjs/layout/screen-scope.d.ts.map +1 -1
  62. package/lib/typescript/commonjs/layout/scroll-metrics-events.d.ts +5 -0
  63. package/lib/typescript/commonjs/layout/scroll-metrics-events.d.ts.map +1 -0
  64. package/lib/typescript/commonjs/layout/scroll-metrics.d.ts +15 -0
  65. package/lib/typescript/commonjs/layout/scroll-metrics.d.ts.map +1 -0
  66. package/lib/typescript/commonjs/layout/scroll-offset.d.ts.map +1 -1
  67. package/lib/typescript/commonjs/layout/use-animated-scroll-metrics-binding.d.ts +19 -0
  68. package/lib/typescript/commonjs/layout/use-animated-scroll-metrics-binding.d.ts.map +1 -0
  69. package/lib/typescript/commonjs/layout/use-scroll-metrics-binding.d.ts +11 -0
  70. package/lib/typescript/commonjs/layout/use-scroll-metrics-binding.d.ts.map +1 -0
  71. package/lib/typescript/commonjs/page-footer/PageFooter.d.ts +1 -1
  72. package/lib/typescript/commonjs/page-footer/PageFooter.d.ts.map +1 -1
  73. package/lib/typescript/commonjs/page-footer/types.d.ts +8 -2
  74. package/lib/typescript/commonjs/page-footer/types.d.ts.map +1 -1
  75. package/lib/typescript/commonjs/page-header/PageHeader.d.ts.map +1 -1
  76. package/lib/typescript/commonjs/page-header/types.d.ts +3 -4
  77. package/lib/typescript/commonjs/page-header/types.d.ts.map +1 -1
  78. package/lib/typescript/module/index.d.ts +2 -2
  79. package/lib/typescript/module/index.d.ts.map +1 -1
  80. package/lib/typescript/module/index.web.d.ts +2 -2
  81. package/lib/typescript/module/index.web.d.ts.map +1 -1
  82. package/lib/typescript/module/layout/ScrollArea.d.ts +11 -0
  83. package/lib/typescript/module/layout/ScrollArea.d.ts.map +1 -0
  84. package/lib/typescript/module/layout/index.d.ts +7 -0
  85. package/lib/typescript/module/layout/index.d.ts.map +1 -1
  86. package/lib/typescript/module/layout/screen-scope.d.ts.map +1 -1
  87. package/lib/typescript/module/layout/scroll-metrics-events.d.ts +5 -0
  88. package/lib/typescript/module/layout/scroll-metrics-events.d.ts.map +1 -0
  89. package/lib/typescript/module/layout/scroll-metrics.d.ts +15 -0
  90. package/lib/typescript/module/layout/scroll-metrics.d.ts.map +1 -0
  91. package/lib/typescript/module/layout/scroll-offset.d.ts.map +1 -1
  92. package/lib/typescript/module/layout/use-animated-scroll-metrics-binding.d.ts +19 -0
  93. package/lib/typescript/module/layout/use-animated-scroll-metrics-binding.d.ts.map +1 -0
  94. package/lib/typescript/module/layout/use-scroll-metrics-binding.d.ts +11 -0
  95. package/lib/typescript/module/layout/use-scroll-metrics-binding.d.ts.map +1 -0
  96. package/lib/typescript/module/page-footer/PageFooter.d.ts +1 -1
  97. package/lib/typescript/module/page-footer/PageFooter.d.ts.map +1 -1
  98. package/lib/typescript/module/page-footer/types.d.ts +8 -2
  99. package/lib/typescript/module/page-footer/types.d.ts.map +1 -1
  100. package/lib/typescript/module/page-header/PageHeader.d.ts.map +1 -1
  101. package/lib/typescript/module/page-header/types.d.ts +3 -4
  102. package/lib/typescript/module/page-header/types.d.ts.map +1 -1
  103. package/package.json +1 -1
  104. package/src/index.ts +5 -1
  105. package/src/index.web.ts +5 -1
  106. package/src/layout/ScrollArea.tsx +25 -0
  107. package/src/layout/index.ts +8 -0
  108. package/src/layout/screen-scope.tsx +2 -1
  109. package/src/layout/scroll-metrics-events.ts +14 -0
  110. package/src/layout/scroll-metrics.tsx +35 -0
  111. package/src/layout/scroll-offset.ts +5 -1
  112. package/src/layout/use-animated-scroll-metrics-binding.ts +42 -0
  113. package/src/layout/use-scroll-metrics-binding.ts +37 -0
  114. package/src/page-footer/PageFooter.tsx +11 -4
  115. package/src/page-footer/types.ts +8 -2
  116. package/src/page-header/PageHeader.tsx +4 -2
  117. package/src/page-header/types.ts +3 -4
package/src/index.web.ts CHANGED
@@ -174,13 +174,17 @@ export {
174
174
  useClaimTopEdge,
175
175
  useTopEdgeInset,
176
176
  ScrollOffsetProvider,
177
+ ScrollMetricsProvider,
178
+ useScrollMetricsBinding,
179
+ useAnimatedScrollMetricsBinding,
180
+ ScrollArea,
177
181
  useScrollOffset,
178
182
  ScreenScope,
179
183
  HeaderDockProvider,
180
184
  useHeaderDockInset,
181
185
  StickySection,
182
186
  } from './layout';
183
- export type { StickySectionProps } from './layout';
187
+ export type { StickySectionProps, ScrollMetricsBindingOptions, AnimatedScrollMetricsBindingOptions, ScrollAreaProps } from './layout';
184
188
  export { Fab } from './fab/index.web';
185
189
  export type { FabProps, FabSize } from './fab/index.web';
186
190
  export { FrostedIconButton } from './frosted-icon-button/index.web';
@@ -0,0 +1,25 @@
1
+ import React, { forwardRef, type ComponentType, type Ref } from 'react';
2
+ import { ScrollView, type ScrollViewProps } from 'react-native';
3
+ import { styled } from 'react-native-css';
4
+ import { useScrollMetricsBinding } from './use-scroll-metrics-binding';
5
+
6
+ export interface ScrollAreaProps extends ScrollViewProps {
7
+ /** Utilities for the scrolling viewport, resolved by Bloom's own interop. */
8
+ className?: string;
9
+ /** Utilities for the inner content container. */
10
+ contentContainerClassName?: string;
11
+ }
12
+
13
+ // Narrow only the styled mapping input to avoid ScrollViewProps' recursive
14
+ // dot-path union. The public component retains the complete ScrollView contract.
15
+ const ScrollAreaBase: ComponentType<Pick<ScrollViewProps, 'style' | 'contentContainerStyle'>> = ScrollView;
16
+ const StyledScrollView: ComponentType<ScrollAreaProps & { ref?: Ref<ScrollView> }> = styled(ScrollAreaBase, {
17
+ className: 'style',
18
+ contentContainerClassName: 'contentContainerStyle',
19
+ });
20
+
21
+ /** A ScrollView that publishes its measurements to surrounding page chrome. */
22
+ export const ScrollArea = forwardRef<ScrollView, ScrollAreaProps>(function ScrollArea({ onScroll, onLayout, onContentSizeChange, scrollEventThrottle, ...props }, ref) {
23
+ const binding = useScrollMetricsBinding({ onScroll, onLayout, onContentSizeChange });
24
+ return <StyledScrollView {...props} {...binding} scrollEventThrottle={scrollEventThrottle ?? binding.scrollEventThrottle} ref={ref} />;
25
+ });
@@ -18,3 +18,11 @@ export { ScreenScope } from './screen-scope';
18
18
  export { HeaderDockProvider, useHeaderDockInset } from './header-dock';
19
19
  export { StickySection } from './StickySection';
20
20
  export type { StickySectionProps } from './StickySection';
21
+
22
+ export { ScrollMetricsProvider } from './scroll-metrics';
23
+ export { useScrollMetricsBinding } from './use-scroll-metrics-binding';
24
+ export type { ScrollMetricsBindingOptions } from './use-scroll-metrics-binding';
25
+ export { ScrollArea } from './ScrollArea';
26
+ export type { ScrollAreaProps } from './ScrollArea';
27
+ export { useAnimatedScrollMetricsBinding } from './use-animated-scroll-metrics-binding';
28
+ export type { AnimatedScrollMetricsBindingOptions } from './use-animated-scroll-metrics-binding';
@@ -39,6 +39,7 @@ import { ScreenContext, ScreenNavigationContext } from '../screen/context';
39
39
  import type { PropsWithChildren } from 'react';
40
40
 
41
41
  import { BottomEdgeProvider } from './bottom-edge';
42
+ import { ScrollMetricsContext } from './scroll-metrics';
42
43
  import { ScrollOffsetProvider } from './scroll-offset';
43
44
  import { TopEdgeProvider } from './top-edge';
44
45
 
@@ -49,7 +50,7 @@ export function ScreenScope({ children }: PropsWithChildren) {
49
50
  <TopEdgeProvider>
50
51
  <BottomEdgeProvider>
51
52
  {/* null means no scroller has claimed this scope yet. */}
52
- <ScrollOffsetProvider value={null}>{children}</ScrollOffsetProvider>
53
+ <ScrollMetricsContext.Provider value={null}><ScrollOffsetProvider value={null}>{children}</ScrollOffsetProvider></ScrollMetricsContext.Provider>
53
54
  </BottomEdgeProvider>
54
55
  </TopEdgeProvider>
55
56
  </ScreenNavigationContext.Provider>
@@ -0,0 +1,14 @@
1
+ import type { NativeScrollEvent } from 'react-native';
2
+ import type { ScrollMetrics } from './scroll-metrics';
3
+
4
+ /** Shared by ordinary RN events and UI-thread animated events. */
5
+ export function recordScrollMetrics(metrics: ScrollMetrics | null, event: NativeScrollEvent): void {
6
+ 'worklet';
7
+ if (!metrics) return;
8
+ const contentHeight = Number.isFinite(event.contentSize.height) ? Math.max(0, event.contentSize.height) : metrics.contentHeight.value;
9
+ const viewportHeight = Number.isFinite(event.layoutMeasurement.height) ? Math.max(0, event.layoutMeasurement.height) : metrics.viewportHeight.value;
10
+ metrics.contentHeight.value = contentHeight;
11
+ metrics.viewportHeight.value = viewportHeight;
12
+ const max = Math.max(0, contentHeight - viewportHeight);
13
+ if (Number.isFinite(event.contentOffset.y)) metrics.scrollY.value = Math.min(max, Math.max(0, event.contentOffset.y));
14
+ }
@@ -0,0 +1,35 @@
1
+ import React, { createContext, useContext, useMemo, type PropsWithChildren } from 'react';
2
+ import { useSharedValue, type SharedValue } from 'react-native-reanimated';
3
+ import { ScrollOffsetProvider } from './scroll-offset';
4
+
5
+ export interface ScrollMetrics {
6
+ scrollY: SharedValue<number>;
7
+ contentHeight: SharedValue<number>;
8
+ viewportHeight: SharedValue<number>;
9
+ }
10
+
11
+ /** Internal scope boundary; overlay screens must not follow the page beneath them. */
12
+ export const ScrollMetricsContext = createContext<ScrollMetrics | null>(null);
13
+
14
+ /** Pair one scroller with its surrounding header/footer without adding a layout box. */
15
+ export function ScrollMetricsProvider({ children }: PropsWithChildren) {
16
+ const scrollY = useSharedValue(0);
17
+ const contentHeight = useSharedValue(-1);
18
+ const viewportHeight = useSharedValue(0);
19
+ const value = useMemo(() => ({ scrollY, contentHeight, viewportHeight }), [scrollY, contentHeight, viewportHeight]);
20
+ return <ScrollMetricsContext.Provider value={value}>
21
+ <ScrollOffsetProvider value={scrollY}>{children}</ScrollOffsetProvider>
22
+ </ScrollMetricsContext.Provider>;
23
+ }
24
+
25
+ export function useScrollMetricsValue(): ScrollMetrics | null {
26
+ return useContext(ScrollMetricsContext);
27
+ }
28
+
29
+ /** Unmeasured and non-scrollable content has no hidden bottom edge. */
30
+ export function remainingScrollDistance(metrics: ScrollMetrics | null): number {
31
+ 'worklet';
32
+ if (!metrics || metrics.viewportHeight.value <= 0 || metrics.contentHeight.value < 0) return 0;
33
+ const max = Math.max(0, metrics.contentHeight.value - metrics.viewportHeight.value);
34
+ return Math.max(0, max - Math.max(0, metrics.scrollY.value));
35
+ }
@@ -8,7 +8,11 @@
8
8
  * one scroll container has moved. The container is the screen's, and the chrome
9
9
  * is usually not inside it, so the number has to travel.
10
10
  *
11
- * Until now it travelled as a prop, and the default when the prop was omitted
11
+ * Prefer ScrollMetricsProvider with ScrollArea when the footer also needs to
12
+ * know the remaining content. This lower-level offset-only provider remains
13
+ * useful for an existing animated scroll owner.
14
+ *
15
+ * Originally it travelled as a prop, and the default when the prop was omitted
12
16
  * was `window.scrollY` on web and "nothing moves" on native. That default is
13
17
  * right for exactly one layout — a web page whose document IS the scroller —
14
18
  * and silently wrong for the two that a Bloom app usually has: a desktop panel
@@ -0,0 +1,42 @@
1
+ import { useCallback } from 'react';
2
+ import type { NativeScrollEvent, NativeSyntheticEvent, ScrollViewProps } from 'react-native';
3
+ import { runOnJS, useAnimatedScrollHandler, useComposedEventHandler, type ScrollHandlerProcessed } from 'react-native-reanimated';
4
+ import { useScrollMetricsValue } from './scroll-metrics';
5
+ import { recordScrollMetrics } from './scroll-metrics-events';
6
+ import { useScrollMetricsBinding } from './use-scroll-metrics-binding';
7
+
8
+ export interface AnimatedScrollMetricsBindingOptions<Context extends Record<string, unknown> = Record<string, unknown>>
9
+ extends Pick<ScrollViewProps, 'onLayout' | 'onContentSizeChange'> {
10
+ /** Optional ordinary RN observer, bridged to JS only when supplied (e.g. restoration.onScroll). */
11
+ onScroll?: ScrollViewProps['onScroll'];
12
+ /** Existing Reanimated handler, including tab-bar minimize or other animated scroll handlers. */
13
+ handler?: ScrollHandlerProcessed<Context> | null;
14
+ }
15
+
16
+ /**
17
+ * Bind an Animated ScrollView / virtualized list with metrics updated on the UI
18
+ * thread. Composition preserves consumer drag and momentum subscriptions.
19
+ */
20
+ export function useAnimatedScrollMetricsBinding<Context extends Record<string, unknown> = Record<string, unknown>>({
21
+ handler = null, onScroll: observeScroll, onLayout, onContentSizeChange,
22
+ }: AnimatedScrollMetricsBindingOptions<Context> = {}) {
23
+ const metrics = useScrollMetricsValue();
24
+ if (observeScroll != null && typeof observeScroll !== 'function') {
25
+ throw new Error('Bloom useAnimatedScrollMetricsBinding onScroll expects a plain RN callback; pass a Reanimated handler through the handler option.');
26
+ }
27
+ // The optional ordinary RN observer (for example scroll restoration) runs on
28
+ // JS. Metrics and composed animated consumers remain on the UI thread.
29
+ const reportScroll = useCallback((event: NativeScrollEvent) => {
30
+ observeScroll?.({ nativeEvent: event } as NativeSyntheticEvent<NativeScrollEvent>);
31
+ }, [observeScroll]);
32
+ const reportsScroll = observeScroll != null;
33
+ const measurements = useScrollMetricsBinding({ onLayout, onContentSizeChange });
34
+ const ownHandler = useAnimatedScrollHandler<Context>({
35
+ onScroll: event => {
36
+ recordScrollMetrics(metrics, event);
37
+ if (reportsScroll) runOnJS(reportScroll)(event);
38
+ },
39
+ }, [metrics, reportsScroll, reportScroll]);
40
+ const onScroll = useComposedEventHandler([ownHandler, handler]);
41
+ return { ...measurements, onScroll };
42
+ }
@@ -0,0 +1,37 @@
1
+ import { useCallback } from 'react';
2
+ import type { ScrollViewProps } from 'react-native';
3
+ import { recordScrollMetrics } from './scroll-metrics-events';
4
+ import { useScrollMetricsValue } from './scroll-metrics';
5
+
6
+ /** Events accepted by ordinary RN ScrollView, FlatList and SectionList. */
7
+ export type ScrollMetricsBindingOptions = Pick<ScrollViewProps, 'onScroll' | 'onLayout' | 'onContentSizeChange'>;
8
+
9
+ /** Bind one scrolling owner to the nearest ScrollMetricsProvider. No React updates per frame. */
10
+ export function useScrollMetricsBinding({ onScroll, onLayout, onContentSizeChange }: ScrollMetricsBindingOptions = {}) {
11
+ const metrics = useScrollMetricsValue();
12
+ // Reanimated types its processed handler as a function, but returns an object.
13
+ // Reject that mismatch before an event instead of attempting to call it on JS.
14
+ if (onScroll != null && typeof onScroll !== 'function') {
15
+ throw new Error('Bloom useScrollMetricsBinding expects a plain RN onScroll callback. Use useAnimatedScrollMetricsBinding({ handler }) for Reanimated handlers.');
16
+ }
17
+ const handleScroll = useCallback<NonNullable<ScrollViewProps['onScroll']>>(event => {
18
+ recordScrollMetrics(metrics, event.nativeEvent);
19
+ onScroll?.(event);
20
+ }, [metrics, onScroll]);
21
+ const handleLayout = useCallback<NonNullable<ScrollViewProps['onLayout']>>(event => {
22
+ const height = event.nativeEvent.layout.height;
23
+ if (metrics && Number.isFinite(height)) {
24
+ metrics.viewportHeight.value = Math.max(0, height);
25
+ if (metrics.contentHeight.value >= 0) metrics.scrollY.value = Math.min(metrics.scrollY.value, Math.max(0, metrics.contentHeight.value - height));
26
+ }
27
+ onLayout?.(event);
28
+ }, [metrics, onLayout]);
29
+ const handleContentSizeChange = useCallback<NonNullable<ScrollViewProps['onContentSizeChange']>>((width, height) => {
30
+ if (metrics && Number.isFinite(height)) {
31
+ metrics.contentHeight.value = Math.max(0, height);
32
+ metrics.scrollY.value = Math.min(metrics.scrollY.value, Math.max(0, height - metrics.viewportHeight.value));
33
+ }
34
+ onContentSizeChange?.(width, height);
35
+ }, [metrics, onContentSizeChange]);
36
+ return { onScroll: handleScroll, onLayout: handleLayout, onContentSizeChange: handleContentSizeChange, scrollEventThrottle: 16 };
37
+ }
@@ -1,6 +1,8 @@
1
1
  import React, { memo, useContext, useEffect, useId, useState } from 'react';
2
2
  import { Platform, StyleSheet, View, useWindowDimensions, type LayoutChangeEvent } from 'react-native';
3
3
  import { SafeAreaInsetsContext } from 'react-native-safe-area-context';
4
+ import Animated, { useAnimatedStyle } from 'react-native-reanimated';
5
+ import { remainingScrollDistance, useScrollMetricsValue } from '../layout/scroll-metrics';
4
6
  import { BloomScope } from '../appearance';
5
7
  import { useBottomEdgeInset } from '../layout/bottom-edge';
6
8
  import { EdgeScrim, SCRIM_TAIL_RATIO } from '../page-header/EdgeScrim';
@@ -15,9 +17,14 @@ const BAR_HEIGHT = 56;
15
17
 
16
18
  /** Page-local controls floating over a bounded scroller, above app navigation. */
17
19
  function PageFooterComponent({
18
- children, actions, bottomInset, safeArea, scrim = 'always', scrimColor, style, testID,
20
+ children, actions, bottomInset, safeArea, scrim = 'always', scrimColor, scrollThreshold = 20, style, testID,
19
21
  }: PageFooterProps) {
20
22
  const theme = useTheme();
23
+ const metrics = useScrollMetricsValue();
24
+ const threshold = Math.max(1, Number.isFinite(scrollThreshold) ? scrollThreshold : 20);
25
+ const scrimStyle = useAnimatedStyle(() => ({
26
+ opacity: scrim === 'auto' ? Math.min(1, remainingScrollDistance(metrics) / threshold) : 1,
27
+ }), [metrics, scrim, threshold]);
21
28
  const fill = useSurfaceFill() ?? theme.colors.background;
22
29
  const occupied = useBottomEdgeInset();
23
30
  const rawBottom = bottomInset ?? occupied;
@@ -51,13 +58,13 @@ function PageFooterComponent({
51
58
  style={[styles.container, { bottom, paddingBottom: padBottom }, style]}
52
59
  >
53
60
  {scrim !== 'none' ? (
54
- <View
61
+ <Animated.View
55
62
  pointerEvents="none"
56
- style={[styles.scrim, { height: height * (1 + SCRIM_TAIL_RATIO) }]}
63
+ style={[styles.scrim, { height: height * (1 + SCRIM_TAIL_RATIO) }, scrimStyle]}
57
64
  testID={testID ? `${testID}-scrim` : undefined}
58
65
  >
59
66
  <EdgeScrim edge="bottom" color={scrimColor ?? fill} testID={testID ? `${testID}-scrim-gradient` : undefined} />
60
- </View>
67
+ </Animated.View>
61
68
  ) : null}
62
69
  <View
63
70
  onLayout={onLayout}
@@ -17,8 +17,14 @@ export interface PageFooterProps {
17
17
  * true on native, false on web. Disable when the containing shell owns it.
18
18
  */
19
19
  safeArea?: boolean;
20
- /** The PageHeader gradient, mirrored upward. Default always. */
21
- scrim?: 'always' | 'none';
20
+ /**
21
+ * Mirrored PageHeader gradient. Default always preserves existing behavior.
22
+ * auto follows ScrollMetricsProvider: hidden at the bottom or for short /
23
+ * unmeasured content, fading in over scrollThreshold of remaining content.
24
+ */
25
+ scrim?: 'auto' | 'always' | 'none';
26
+ /** Distance from the bottom over which the automatic scrim fades. Default 20. */
27
+ scrollThreshold?: number;
22
28
  /** Opaque gradient color; defaults to the containing surface's fill. */
23
29
  scrimColor?: string;
24
30
  style?: StyleProp<ViewStyle>;
@@ -24,6 +24,7 @@ import { BUTTON_SHADOW } from '../button/shared';
24
24
  import { BloomScope } from "../appearance";
25
25
  import { GlassIsland } from '../glass';
26
26
  import { RiArrowLeftLine } from '../icons/remix/RiArrowLeftLine';
27
+ import { useScrollMetricsValue } from '../layout/scroll-metrics';
27
28
  import { useClaimTopEdge, useScrollOffset } from '../layout';
28
29
  import { BREAKPOINTS } from '../styles/breakpoints';
29
30
  import { WEB_POSITION_STICKY, WEB_SURFACE_STICKY_TOP, type WebCssStyle } from '../styles/web-view-style';
@@ -186,14 +187,15 @@ function PageHeaderComponent({
186
187
 
187
188
  // ── The scroll owner ──────────────────────────────────────────────────────
188
189
  //
189
- // Prop, then Screen, then the nearest ScrollOffsetProvider, then the document on web.
190
+ // Explicit offset, dock, local metrics, Screen, legacy offset, then the web document.
190
191
  // The context step is the one that matters for a Bloom composition: without
191
192
  // it a header inside a scrolling panel followed `window.scrollY`, which on a
192
193
  // desktop shell never moves, so the header looked deliberately inert.
193
194
  const screen = useScreenContext();
194
195
  const contextScrollY = useScrollOffset();
196
+ const metrics = useScrollMetricsValue();
195
197
  const internalScrollY = useSharedValue(0);
196
- const scrollY = externalScrollY ?? dock?.scrollY ?? screen?.scrollY ?? contextScrollY ?? internalScrollY;
198
+ const scrollY = externalScrollY ?? dock?.scrollY ?? metrics?.scrollY ?? screen?.scrollY ?? contextScrollY ?? internalScrollY;
197
199
  const followsWindow = !externalScrollY && !dock && !screen && !contextScrollY;
198
200
 
199
201
  useEffect(() => {
@@ -142,10 +142,9 @@ export interface PageHeaderProps {
142
142
  /**
143
143
  * The scroll offset driving the scrim, the separator and the shadow.
144
144
  *
145
- * Resolution order: this prop, then the enclosing `Screen` offset, then the nearest `ScrollOffsetProvider`
146
- * (`@oxy.so/bloom/layout`) — which is how a header inside a Bloom scroll
147
- * composition gets it with no wiring — then, on web only, `window.scrollY`.
148
- * On native with none of the three, the header stays at rest.
145
+ * Resolution order: this prop, HeaderDockProvider, ScrollMetricsProvider,
146
+ * the enclosing Screen, then ScrollOffsetProvider. On web with no local
147
+ * owner it follows window.scrollY; on native it stays at rest.
149
148
  */
150
149
  scrollY?: SharedValue<number>;
151
150
  /** The offset (px) over which the scrim, separator and shadow fade in. Default 20. */