@delacour/react-native-ui 0.5.0-alpha.20261024151637 → 0.5.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 (45) hide show
  1. package/package.json +3 -3
  2. package/src/components/carousel/carousel-caption.tsx +1 -1
  3. package/src/components/carousel/carousel-dots.tsx +2 -2
  4. package/src/components/carousel/carousel-item.tsx +1 -1
  5. package/src/components/circular-text/circular-text-spin.ts +1 -2
  6. package/src/components/compare/compare.tsx +1 -2
  7. package/src/components/dialog/dialog-content.tsx +1 -1
  8. package/src/components/drawer/drawer-content.tsx +2 -3
  9. package/src/components/flip-card/flip-card.tsx +1 -2
  10. package/src/components/image-viewer/image-viewer-chrome.tsx +1 -1
  11. package/src/components/image-viewer/image-viewer-layer.tsx +6 -9
  12. package/src/components/image-viewer/image-viewer-page.tsx +2 -5
  13. package/src/components/input/AGENTS.md +6 -0
  14. package/src/components/input/index.ts +1 -0
  15. package/src/components/input/input.tsx +7 -0
  16. package/src/components/input/input.variants.test.ts +20 -0
  17. package/src/components/input/input.variants.ts +19 -0
  18. package/src/components/marquee/marquee-track.tsx +1 -2
  19. package/src/components/message-scroller/use-message-scroller-state.ts +1 -2
  20. package/src/components/page-header/page-header-cover.tsx +2 -2
  21. package/src/components/screen/screen-footer-background.tsx +7 -10
  22. package/src/components/screen/screen-navbar-background.tsx +1 -4
  23. package/src/components/screen/screen-view.tsx +1 -1
  24. package/src/components/screen/use-screen-scroll-insets.ts +24 -30
  25. package/src/components/scroll-canvas/use-frame-progress.ts +1 -2
  26. package/src/components/scroll-fade/AGENTS.md +17 -3
  27. package/src/components/scroll-fade/scroll-fade-edge.tsx +37 -7
  28. package/src/components/scroll-fade/scroll-fade.tsx +13 -2
  29. package/src/components/scroll-header/scroll-header-root.tsx +3 -6
  30. package/src/components/scroll-header/use-scroll-header-scroll-props.ts +4 -7
  31. package/src/components/scroll-text/scroll-text-source.ts +1 -2
  32. package/src/components/section-progress/section-progress.tsx +3 -6
  33. package/src/components/slider/slider-track.tsx +1 -2
  34. package/src/components/sortable/sortable-item.tsx +2 -4
  35. package/src/components/tabs/tabs-scroll-view.tsx +1 -2
  36. package/src/components/tabs/tabs-separator.tsx +1 -4
  37. package/src/components/tabs/tabs.tsx +1 -2
  38. package/src/components/text-animation/text-animation-typing.tsx +1 -2
  39. package/src/components/timeline/timeline-title.tsx +1 -2
  40. package/src/components/timeline/use-timeline-reading-edge.ts +8 -11
  41. package/src/hooks/use-scroll-source.tsx +23 -26
  42. package/src/lib/scroll-edges.test.ts +36 -1
  43. package/src/lib/scroll-edges.ts +23 -0
  44. package/src/lib/scroll-maths.test.ts +27 -0
  45. package/src/lib/scroll-maths.ts +11 -2
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@delacour/react-native-ui",
3
- "version": "0.5.0-alpha.20261024151637",
3
+ "version": "0.5.0",
4
4
  "description": "React Native UI components — Uniwind, Reanimated, Gesture Handler",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -182,8 +182,8 @@
182
182
  "expo-blur": ">=15",
183
183
  "@maplibre/maplibre-react-native": "^11.5.0",
184
184
  "@central-icons-react-native/round-outlined-radius-1-stroke-1.5": "^1.1",
185
- "@delacour/react-native-bottom-sheet": "0.3.0-alpha.20261024151637",
186
- "@delacour/react-native-charts": "0.2.0-alpha.20261024151637",
185
+ "@delacour/react-native-bottom-sheet": ">=0.3.0",
186
+ "@delacour/react-native-charts": ">=0.2.0",
187
187
  "@legendapp/list": ">=3.3",
188
188
  "react-native-skia": ">=3.7.3",
189
189
  "expo-linear-gradient": ">=15",
@@ -30,7 +30,7 @@ export function CarouselCaption({ className, ...props }: CarouselCaptionProps):
30
30
  const animatedStyle = useAnimatedStyle(() => {
31
31
  const offset = loop ? resolveWrappedOffset(index, position.value, count) : index - position.value;
32
32
  return { opacity: resolveCaptionOpacity(offset) };
33
- }, [count, index, loop]);
33
+ });
34
34
 
35
35
  const slots = carouselVariants({ orientation, variant });
36
36
 
@@ -104,12 +104,12 @@ function CarouselDot({
104
104
  const offset = loop ? resolveWrappedOffset(index, position.value, count) : index - position.value;
105
105
  const length = resolveDotLength(offset);
106
106
  return isHorizontal ? { width: length } : { height: length };
107
- }, [count, index, isHorizontal, loop]);
107
+ });
108
108
 
109
109
  const fillStyle = useAnimatedStyle(() => {
110
110
  const offset = loop ? resolveWrappedOffset(index, position.value, count) : index - position.value;
111
111
  return { opacity: resolveCaptionOpacity(offset) };
112
- }, [count, index, loop]);
112
+ });
113
113
 
114
114
  return (
115
115
  <Animated.View className={className} style={dotStyle}>
@@ -86,7 +86,7 @@ export function CarouselItem({
86
86
  ],
87
87
  zIndex: Math.round(100 - distance * 10),
88
88
  };
89
- }, [count, index, isCalm, isCoverflow, isHorizontal, lengthKey, loop, turnSign, windowSize]);
89
+ });
90
90
 
91
91
  const accessibility = resolveSlideAccessibility({ isActive, isPeek });
92
92
  const itemClassName = carouselVariants({ orientation, variant }).item();
@@ -61,8 +61,7 @@ export function useCircularSpin({
61
61
  () => isPaused || isPressed.value,
62
62
  (paused) => {
63
63
  velocity.value = withTiming(paused ? 0 : target, { duration: CIRCULAR_TEXT_EASE_MS });
64
- },
65
- [isPaused, target]
64
+ }
66
65
  );
67
66
 
68
67
  return rotation;
@@ -341,8 +341,7 @@ function CompareRoot({
341
341
  (next, previous) => {
342
342
  if (next < 0 || next === previous) return;
343
343
  scheduleOnRN(commitLive, next);
344
- },
345
- [commitLive]
344
+ }
346
345
  );
347
346
 
348
347
  useUnnamedWarning(accessibilityLabel);
@@ -123,7 +123,7 @@ export function DialogContent({
123
123
  { scale: interpolate(progress.value, [0, 1], [DIALOG_ENTER_SCALE, 1]) },
124
124
  ],
125
125
  };
126
- }, [isReduced, topInset, windowHeight]);
126
+ });
127
127
 
128
128
  if (!isPresent) return null;
129
129
 
@@ -126,8 +126,7 @@ export function DrawerContent({
126
126
  () => progress.value * (1 - resolveDrawerDragFraction(edge, drag.value, extent)),
127
127
  (visibility) => {
128
128
  scrimProgress.value = visibility;
129
- },
130
- [edge, extent]
129
+ }
131
130
  );
132
131
 
133
132
  const panelStyle = useAnimatedStyle(() => {
@@ -139,7 +138,7 @@ export function DrawerContent({
139
138
  { translateY: slide.translateY + (isHorizontal ? 0 : drag.value) },
140
139
  ],
141
140
  };
142
- }, [edge, extent, isHorizontal, isReduced]);
141
+ });
143
142
 
144
143
  const panel = useMemo<DrawerPanelValue>(() => ({ edge, side }), [edge, side]);
145
144
 
@@ -265,8 +265,7 @@ function FlipCardRoot(props: FlipCardProps): ReactElement {
265
265
  if (!shouldTickFlipHaptic(hapticSide.value, next)) return;
266
266
  hapticSide.value = next >= 90 ? 180 : 0;
267
267
  if (haptic) playHaptic(haptic);
268
- },
269
- [haptic]
268
+ }
270
269
  );
271
270
 
272
271
  const isY = axis === "y";
@@ -50,7 +50,7 @@ export function ImageViewerChrome({
50
50
  return {
51
51
  opacity: fade.value * flight * chrome.value * zoomFade * resolveBackdropOpacity(dismissY.value, windowHeight),
52
52
  };
53
- }, [windowHeight]);
53
+ });
54
54
 
55
55
  const pointerEvents = isVisible ? "box-none" : "none";
56
56
 
@@ -493,15 +493,12 @@ export function ImageViewerLayer({
493
493
  windowWidth,
494
494
  ]);
495
495
 
496
- const backdropStyle = useAnimatedStyle(
497
- () => ({
498
- opacity:
499
- fade.value *
500
- (progress.value < 0 ? 0 : progress.value > 1 ? 1 : progress.value) *
501
- resolveBackdropOpacity(dismissY.value, windowHeight),
502
- }),
503
- [windowHeight]
504
- );
496
+ const backdropStyle = useAnimatedStyle(() => ({
497
+ opacity:
498
+ fade.value *
499
+ (progress.value < 0 ? 0 : progress.value > 1 ? 1 : progress.value) *
500
+ resolveBackdropOpacity(dismissY.value, windowHeight),
501
+ }));
505
502
 
506
503
  const stageStyle = useAnimatedStyle(() => ({ opacity: fade.value }));
507
504
 
@@ -90,10 +90,7 @@ export function ImageViewerPage({
90
90
 
91
91
  const { height: fittedHeight, width: fittedWidth, x: fittedX, y: fittedY } = fitted;
92
92
 
93
- const pageStyle = useAnimatedStyle(
94
- () => ({ transform: [{ translateX: (pageIndex - position.value) * pitch }] }),
95
- [pageIndex, pitch]
96
- );
93
+ const pageStyle = useAnimatedStyle(() => ({ transform: [{ translateX: (pageIndex - position.value) * pitch }] }));
97
94
 
98
95
  const frameStyle = useAnimatedStyle(() => {
99
96
  const rest = { height: fittedHeight, width: fittedWidth, x: fittedX, y: fittedY };
@@ -114,7 +111,7 @@ export function ImageViewerPage({
114
111
  ],
115
112
  width: frame.width,
116
113
  };
117
- }, [fittedHeight, fittedWidth, fittedX, fittedY, isActive, windowHeight]);
114
+ });
118
115
 
119
116
  const fullStyle = useAnimatedStyle(() => ({ opacity: fullOpacity.value }));
120
117
 
@@ -97,6 +97,12 @@ Root plus `Input.Group` and its parts: `Prefix`, `Suffix`, `Button`,
97
97
  [`Text.Label`](../text/AGENTS.md) and `Text.Caption` already are those, and a label defined twice is
98
98
  a type scale that can drift. `apps/playground`'s `/input/form` is what the
99
99
  trade looks like at a call site.
100
+ - **A `Field.Label` names the field.** `resolveInputAccessibilityLabel` takes an
101
+ explicit `accessibilityLabel` first and the enclosing [`Field`](../field/AGENTS.md)'s
102
+ `field.label` second, so the documented `<Field><Field.Label>Email</Field.Label><Input /></Field>`
103
+ announces "Email" under VoiceOver rather than an unnamed text field. The
104
+ same fallback [`OtpInput`](../otp-input/AGENTS.md) makes; `Textarea` renders
105
+ an `Input`, so it gets it too. With neither it stays `undefined`, not `""`.
100
106
  - **`--spacing-input-*` is its own scale**, matching `--spacing-button-*` in
101
107
  value and not in name — this is the case [Sizing](../../../AGENTS.md#sizing)
102
108
  anticipated. A token test asserts the two stay level, so either can be retuned
@@ -28,6 +28,7 @@ export {
28
28
  resolveGroupButtonClass,
29
29
  resolveGroupButtonSize,
30
30
  resolveGroupSegments,
31
+ resolveInputAccessibilityLabel,
31
32
  resolveInputFieldClass,
32
33
  resolveInputGroupClass,
33
34
  resolveInputGroupRowClass,
@@ -8,6 +8,7 @@ import { useInputGroupContext } from "./input.context";
8
8
  import {
9
9
  type InputSize,
10
10
  type InputVariant,
11
+ resolveInputAccessibilityLabel,
11
12
  resolveInputFieldClass,
12
13
  resolvePlaceholderAccentClass,
13
14
  resolveSelectionAccentClass,
@@ -60,6 +61,7 @@ function InputRoot({
60
61
  className,
61
62
  placeholderColorClassName,
62
63
  selectionColorClassName,
64
+ accessibilityLabel,
63
65
  editable,
64
66
  multiline,
65
67
  onBlur,
@@ -148,6 +150,7 @@ function InputRoot({
148
150
  selectionColorClassName={selectionAccent}
149
151
  textAlignVertical={multiline ? "top" : undefined}
150
152
  {...props}
153
+ accessibilityLabel={resolveInputAccessibilityLabel(accessibilityLabel, field?.label)}
151
154
  editable={resolved.isDisabled ? false : editable}
152
155
  onBlur={handleBlur}
153
156
  onFocus={handleFocus}
@@ -179,6 +182,10 @@ function InputRoot({
179
182
  * Wrap it in an `Input.Group` to put an icon, an affix or a control inside the
180
183
  * field's own box.
181
184
  *
185
+ * Inside a `Field`, the text of its `Field.Label` becomes the field's accessible
186
+ * name when no `accessibilityLabel` is given, so a screen reader announces
187
+ * which value the editor wants.
188
+ *
182
189
  * @example
183
190
  * <Input onChangeText={setEmail} placeholder="Email" value={email} />
184
191
  *
@@ -19,6 +19,7 @@ import {
19
19
  resolveGroupButtonClass,
20
20
  resolveGroupButtonSize,
21
21
  resolveGroupSegments,
22
+ resolveInputAccessibilityLabel,
22
23
  resolveInputFieldClass,
23
24
  resolveInputGroupClass,
24
25
  resolveInputGroupRowClass,
@@ -658,3 +659,22 @@ describe("resolveGroupSegments", () => {
658
659
  expect(resolveGroupSegments([])).toEqual([]);
659
660
  });
660
661
  });
662
+
663
+ describe("resolveInputAccessibilityLabel", () => {
664
+ test("an explicit label wins over the enclosing Field.Label", () => {
665
+ expect(resolveInputAccessibilityLabel("Work email", "Email")).toBe("Work email");
666
+ });
667
+
668
+ test("falls back to the enclosing Field.Label's text", () => {
669
+ expect(resolveInputAccessibilityLabel(undefined, "Email")).toBe("Email");
670
+ });
671
+
672
+ test("names nothing outside a Field, or under a label with no text", () => {
673
+ expect(resolveInputAccessibilityLabel(undefined, null)).toBeUndefined();
674
+ expect(resolveInputAccessibilityLabel(undefined, undefined)).toBeUndefined();
675
+ });
676
+
677
+ test("an explicit label still wins when no Field is enclosing", () => {
678
+ expect(resolveInputAccessibilityLabel("Search", null)).toBe("Search");
679
+ });
680
+ });
@@ -511,4 +511,23 @@ export function resolveSelectionAccentClass({
511
511
  return cn(isInvalid ? INPUT_INVALID_SELECTION_ACCENT_CLASS : INPUT_SELECTION_ACCENT_CLASS, className);
512
512
  }
513
513
 
514
+ /**
515
+ * The accessible name a screen reader announces when the field takes focus.
516
+ *
517
+ * The caller's `accessibilityLabel` wins; otherwise the text an enclosing
518
+ * `Field.Label` registered with the field's context, so
519
+ * `<Field><Field.Label>Email</Field.Label><Input /></Field>` reads "Email"
520
+ * rather than an unnamed text field. React Native has no `<label for>`; this is
521
+ * that association, the same way `OtpInput` makes it. With neither there is no
522
+ * name, and `undefined` leaves the native default rather than an empty string.
523
+ *
524
+ * Pure, so it is unit-testable. See AGENTS.md.
525
+ */
526
+ export function resolveInputAccessibilityLabel(
527
+ own: string | undefined,
528
+ fieldLabel: string | null | undefined
529
+ ): string | undefined {
530
+ return own ?? fieldLabel ?? undefined;
531
+ }
532
+
514
533
  export type InputVariantProps = VariantProps<typeof inputVariants>;
@@ -87,8 +87,7 @@ export function MarqueeTrack({
87
87
  easeMs > 0
88
88
  ? withTiming(next, { duration: easeMs, easing: Easing.out(Easing.quad), reduceMotion: ReduceMotion.Never })
89
89
  : next;
90
- },
91
- [pause, target, easeMs]
90
+ }
92
91
  );
93
92
 
94
93
  useFrameCallback((frame) => {
@@ -99,8 +99,7 @@ export function useMessageScrollerState({
99
99
  isAtEndValue.value = atEnd;
100
100
  scheduleOnRN(setIsAtEnd, atEnd);
101
101
  }
102
- },
103
- [stickThreshold]
102
+ }
104
103
  );
105
104
 
106
105
  useEffect(() => {
@@ -123,7 +123,7 @@ export function PageHeaderCover({
123
123
  height: resolvePageHeaderCoverHeight({ variant, height, topInset: inset, isInsetAware }),
124
124
  marginTop: resolvePageHeaderCoverOffset({ variant, isInsetAware, isInScreen, topInset: inset }),
125
125
  };
126
- }, [navbar, top, variant, height, isInsetAware, isInScreen]);
126
+ });
127
127
 
128
128
  const overlayStyle = useAnimatedStyle(() => {
129
129
  const inset = navbar
@@ -136,7 +136,7 @@ export function PageHeaderCover({
136
136
  return {
137
137
  top: resolvePageHeaderCoverHeight({ variant, height, topInset: inset, isInsetAware }) - Math.max(0, height),
138
138
  };
139
- }, [navbar, top, variant, height, isInsetAware]);
139
+ });
140
140
 
141
141
  const isDescribed = accessibilityLabel !== undefined;
142
142
 
@@ -35,17 +35,14 @@ export const ScreenFooterBackground = memo(function ScreenFooterBackground({
35
35
  const debug = useScreenDebug();
36
36
  const slots = screenVariants({ placement });
37
37
 
38
- const borderStyle = useAnimatedStyle(
39
- () => ({
40
- opacity: resolveFooterBorderOpacity({
41
- contentHeight: contentHeight.value,
42
- fadeOnScroll,
43
- layoutHeight: layoutHeight.value,
44
- scrollY: scrollY.value,
45
- }),
38
+ const borderStyle = useAnimatedStyle(() => ({
39
+ opacity: resolveFooterBorderOpacity({
40
+ contentHeight: contentHeight.value,
41
+ fadeOnScroll,
42
+ layoutHeight: layoutHeight.value,
43
+ scrollY: scrollY.value,
46
44
  }),
47
- [fadeOnScroll]
48
- );
45
+ }));
49
46
 
50
47
  return (
51
48
  <View
@@ -31,10 +31,7 @@ export const ScreenNavbarBackground = memo(function ScreenNavbarBackground({
31
31
  }: ScreenNavbarBackgroundProps): ReactElement {
32
32
  const { scrollY } = useScreenPart("Screen.Navbar");
33
33
 
34
- const borderStyle = useAnimatedStyle(
35
- () => ({ opacity: resolveNavbarBorderOpacity(scrollY.value, fadeOnScroll) }),
36
- [fadeOnScroll]
37
- );
34
+ const borderStyle = useAnimatedStyle(() => ({ opacity: resolveNavbarBorderOpacity(scrollY.value, fadeOnScroll) }));
38
35
 
39
36
  return (
40
37
  <View className={screenVariants().navbarBackground({ className })} pointerEvents="none">
@@ -46,7 +46,7 @@ export function ScreenView({ className, style, children, ...props }: ScreenViewP
46
46
  safeAreaBottom: bottom,
47
47
  safeAreaTop: top,
48
48
  });
49
- }, [top, bottom]);
49
+ });
50
50
 
51
51
  return (
52
52
  <Animated.View className="flex-1" style={animatedStyle}>
@@ -43,8 +43,7 @@ function useFooterBaselineHeight(estimatedHeight = 0): number {
43
43
  (current, previous) => {
44
44
  if (current <= 0 || current === previous) return;
45
45
  scheduleOnRN(setMeasured, current);
46
- },
47
- []
46
+ }
48
47
  );
49
48
 
50
49
  return measured;
@@ -83,8 +82,7 @@ export function useChatComposerInset(): DerivedValue<number> {
83
82
  const { bottom } = useSafeAreaInsets();
84
83
 
85
84
  return useDerivedValue(
86
- () => footerOccupancy(footer.height.value, bottom) + footer.overlayHeight.value + CHAT_COMPOSER_GAP,
87
- [bottom]
85
+ () => footerOccupancy(footer.height.value, bottom) + footer.overlayHeight.value + CHAT_COMPOSER_GAP
88
86
  );
89
87
  }
90
88
 
@@ -98,8 +96,7 @@ export function useChatComposerGrowthPadding(): DerivedValue<number> {
98
96
  const { footer } = useScreenPart("useChatComposerGrowthPadding");
99
97
 
100
98
  return useDerivedValue(
101
- () => Math.max(0, footer.height.value - footer.initialHeight.value) + footer.overlayHeight.value,
102
- []
99
+ () => Math.max(0, footer.height.value - footer.initialHeight.value) + footer.overlayHeight.value
103
100
  );
104
101
  }
105
102
 
@@ -177,29 +174,26 @@ export function useScreenScrollInsets(mode: ScreenScrollInsetMode, onScroll?: Sc
177
174
  const caller = classifyScrollHandler(onScroll);
178
175
  const callerCallback = caller.kind === "js" ? caller.handler : undefined;
179
176
 
180
- const trackingHandler = useAnimatedScrollHandler(
181
- {
182
- onScroll: (event) => {
183
- scrollY.value = event.contentOffset.y;
184
-
185
- if (event.contentSize.height !== contentHeight.value) {
186
- contentHeight.value = event.contentSize.height;
187
- }
188
-
189
- if (event.layoutMeasurement.height !== layoutHeight.value) {
190
- layoutHeight.value = event.layoutMeasurement.height;
191
- }
192
-
193
- // Only `nativeEvent` is real: a worklet sees the native payload, not
194
- // React Native's synthetic wrapper, so `persist()` and the rest of the
195
- // wrapper do not exist. A caller needing more passes a worklet handler.
196
- if (callerCallback) {
197
- scheduleOnRN(callerCallback, { nativeEvent: event } as unknown as NativeSyntheticEvent<NativeScrollEvent>);
198
- }
199
- },
177
+ const trackingHandler = useAnimatedScrollHandler({
178
+ onScroll: (event) => {
179
+ scrollY.value = event.contentOffset.y;
180
+
181
+ if (event.contentSize.height !== contentHeight.value) {
182
+ contentHeight.value = event.contentSize.height;
183
+ }
184
+
185
+ if (event.layoutMeasurement.height !== layoutHeight.value) {
186
+ layoutHeight.value = event.layoutMeasurement.height;
187
+ }
188
+
189
+ // Only `nativeEvent` is real: a worklet sees the native payload, not
190
+ // React Native's synthetic wrapper, so `persist()` and the rest of the
191
+ // wrapper do not exist. A caller needing more passes a worklet handler.
192
+ if (callerCallback) {
193
+ scheduleOnRN(callerCallback, { nativeEvent: event } as unknown as NativeSyntheticEvent<NativeScrollEvent>);
194
+ }
200
195
  },
201
- [callerCallback]
202
- );
196
+ });
203
197
 
204
198
  // A caller's `useAnimatedScrollHandler` runs beside the tracking on the UI
205
199
  // thread rather than replacing it — one `onScroll` prop, two worklets.
@@ -216,7 +210,7 @@ export function useScreenScrollInsets(mode: ScreenScrollInsetMode, onScroll?: Sc
216
210
  safeAreaTop: top,
217
211
  }),
218
212
  };
219
- }, [top]);
213
+ });
220
214
 
221
215
  const insetBottomAnimatedStyle = useAnimatedStyle(() => {
222
216
  return {
@@ -229,7 +223,7 @@ export function useScreenScrollInsets(mode: ScreenScrollInsetMode, onScroll?: Sc
229
223
  safeAreaBottom: bottom,
230
224
  }),
231
225
  };
232
- }, [mode, bottom]);
226
+ });
233
227
 
234
228
  return { insetBottomAnimatedStyle, insetTopAnimatedStyle, scrollHandler };
235
229
  }
@@ -106,8 +106,7 @@ export function useFrameProgress(options: UseFrameProgressOptions): FrameProgres
106
106
  () => (contentSize ? contentSize.value : 0),
107
107
  (next, previous) => {
108
108
  if (scroller && previous !== null && next !== previous) scheduleOnRN(measure);
109
- },
110
- [contentSize, scroller, measure]
109
+ }
111
110
  );
112
111
 
113
112
  const resting = resolveStaticProgress(effect);
@@ -13,8 +13,8 @@ none of them; it reads them:
13
13
  | Module | What ScrollFade takes from it |
14
14
  | --- | --- |
15
15
  | `src/hooks/use-scroll-source.tsx` | `useResolvedScrollSource` and the `ScrollSource` type |
16
- | `src/lib/scroll-maths.ts` | `scrollEdgeOpacity`, the per-edge opacity worklet |
17
- | `src/lib/scroll-edges.ts` | `SCROLL_EDGES`, `resolveFadeEdges`, `resolveFadeBand`, `resolveFadeGradient` |
16
+ | `src/lib/scroll-maths.ts` | `scrollEdgeOpacity`, the per-edge opacity worklet, and its `coveredEnd` |
17
+ | `src/lib/scroll-edges.ts` | `SCROLL_EDGES`, `resolveFadeEdges`, `resolveFadeBand`, `resolveFadeGradient`, `resolveKeyboardLift` |
18
18
 
19
19
  Their rules and tests live with them. Nothing here keeps a copy.
20
20
 
@@ -24,7 +24,7 @@ Their rules and tests live with them. Nothing here keeps a copy.
24
24
  | --- | --- |
25
25
  | `index.ts` | → `@delacour/react-native-ui/scroll-fade` |
26
26
  | `scroll-fade.tsx` | `ScrollFade` — resolves the source and the colour, mounts the edges |
27
- | `scroll-fade-edge.tsx` | One animated edge band — internal, memoised |
27
+ | `scroll-fade-edge.tsx` | One animated edge band, and its keyboard-aware wrapper — internal, memoised |
28
28
  | `scroll-fade.variants.ts` | Pure `tv()` slots, the defaults, and `SCROLL_FADE_EDGES` — `lib/scroll-edges`' union under ScrollFade's name |
29
29
  | `scroll-fade.variants.test.ts` | |
30
30
 
@@ -76,6 +76,20 @@ Their rules and tests live with them. Nothing here keeps a copy.
76
76
  - **`insetStart` / `insetEnd` hold a band in from its edge** — under a bar this
77
77
  component cannot see. It is not `Screen.ScrollShadow`'s `coverTop`: there is no
78
78
  solid band here.
79
+ - **`keyboardAware` lifts the end band above an open keyboard.** A keyboard
80
+ covers a scrollable without resizing it, so a band pinned to the bottom of its
81
+ box sits under the keyboard. It is not only hidden there:
82
+ `KeyboardAwareScrollView` makes room with a content inset (its default
83
+ `insets` mode), and a scroll event leaves an inset out of `contentSize`, so a
84
+ form that fits the whole frame reads as too short to scroll and the end clears
85
+ for good. The lift is the keyboard less the bottom safe-area inset —
86
+ `resolveKeyboardLift`, the same distance a sticky `Screen.Footer` moves — and
87
+ one animated style spends it twice: as the band's `translateY`, and as
88
+ `coveredEnd`, so the opacity measures against the frame still visible. Off by
89
+ default; only a vertical end band moves. Only a keyboard-aware end edge
90
+ (`ScrollFadeKeyboardEdge`) calls keyboard-controller's hook, so a fade outside
91
+ a `KeyboardProvider` — a card row, a sheet — keeps working without its
92
+ warning.
79
93
  - **`enabled={false}` draws nothing without unmounting**, so the scrollable keeps
80
94
  its position and state when a fade is toggled.
81
95
 
@@ -1,9 +1,11 @@
1
1
  import { LinearGradient } from "expo-linear-gradient";
2
2
  import { memo, type ReactElement } from "react";
3
3
  import { StyleSheet } from "react-native";
4
- import Animated, { useAnimatedStyle } from "react-native-reanimated";
4
+ import { useReanimatedKeyboardAnimation } from "react-native-keyboard-controller";
5
+ import Animated, { type SharedValue, useAnimatedStyle, useDerivedValue } from "react-native-reanimated";
6
+ import { useSafeAreaInsets } from "react-native-safe-area-context";
5
7
  import type { ScrollSource } from "../../hooks/use-scroll-source";
6
- import { resolveFadeBand, resolveFadeGradient } from "../../lib/scroll-edges";
8
+ import { resolveFadeBand, resolveFadeGradient, resolveKeyboardLift } from "../../lib/scroll-edges";
7
9
  import { type ScrollEdge, scrollEdgeOpacity } from "../../lib/scroll-maths";
8
10
  import { scrollFadeVariants } from "./scroll-fade.variants";
9
11
 
@@ -19,6 +21,8 @@ export type ScrollFadeEdgeProps = {
19
21
  color: string;
20
22
  /** The same colour at zero alpha, for the far stop — never the `transparent` keyword. */
21
23
  faded: string;
24
+ /** Points the band rises by, and the frame's end it treats as covered. See `resolveKeyboardLift`. */
25
+ lift?: SharedValue<number>;
22
26
  };
23
27
 
24
28
  /**
@@ -39,21 +43,25 @@ export const ScrollFadeEdge = memo(function ScrollFadeEdge({
39
43
  fadeInDistance,
40
44
  color,
41
45
  faded,
46
+ lift,
42
47
  }: ScrollFadeEdgeProps): ReactElement {
43
48
  const { offset, contentSize, layoutSize, orientation } = source;
44
49
 
45
- const opacityStyle = useAnimatedStyle(
46
- () => ({
50
+ // One value spent twice, so the band's position and its opacity cannot disagree.
51
+ const opacityStyle = useAnimatedStyle(() => {
52
+ const covered = lift === undefined ? 0 : lift.value;
53
+ return {
47
54
  opacity: scrollEdgeOpacity({
48
55
  edge,
49
56
  offset: offset.value,
50
57
  contentSize: contentSize.value,
51
58
  layoutSize: layoutSize.value,
52
59
  fadeInDistance,
60
+ coveredEnd: covered,
53
61
  }),
54
- }),
55
- [edge, fadeInDistance]
56
- );
62
+ transform: [{ translateY: -covered }],
63
+ };
64
+ });
57
65
 
58
66
  const band = resolveFadeBand({ orientation, edge, size, inset });
59
67
  const { start, end } = resolveFadeGradient({ orientation, edge });
@@ -71,3 +79,25 @@ export const ScrollFadeEdge = memo(function ScrollFadeEdge({
71
79
  );
72
80
  });
73
81
  ScrollFadeEdge.displayName = "DelacourUI.ScrollFade.Edge";
82
+
83
+ /**
84
+ * An edge band that rises above an open keyboard.
85
+ *
86
+ * A separate component so the keyboard is read only where it is wanted. A plain
87
+ * edge never touches keyboard-controller, so a fade outside a
88
+ * `KeyboardProvider` — a card row, a sheet — keeps working without its warning.
89
+ */
90
+ export const ScrollFadeKeyboardEdge = memo(function ScrollFadeKeyboardEdge(
91
+ props: Omit<ScrollFadeEdgeProps, "lift">
92
+ ): ReactElement {
93
+ const { edge, source } = props;
94
+ const { orientation } = source;
95
+ const keyboard = useReanimatedKeyboardAnimation();
96
+ const { bottom: safeAreaBottom } = useSafeAreaInsets();
97
+ const lift = useDerivedValue(() =>
98
+ resolveKeyboardLift({ orientation, edge, keyboardHeight: keyboard.height.value, safeAreaBottom })
99
+ );
100
+
101
+ return <ScrollFadeEdge {...props} lift={lift} />;
102
+ });
103
+ ScrollFadeKeyboardEdge.displayName = "DelacourUI.ScrollFade.KeyboardEdge";
@@ -10,7 +10,7 @@ import {
10
10
  type ScrollFadeEdges,
11
11
  scrollFadeVariants,
12
12
  } from "./scroll-fade.variants";
13
- import { ScrollFadeEdge } from "./scroll-fade-edge";
13
+ import { ScrollFadeEdge, ScrollFadeKeyboardEdge } from "./scroll-fade-edge";
14
14
 
15
15
  export type ScrollFadeProps = Omit<ViewProps, "pointerEvents"> & {
16
16
  /** The scrollable (and anything else) the fade overlays. Fades paint after it, so they sit on top. */
@@ -33,6 +33,14 @@ export type ScrollFadeProps = Omit<ViewProps, "pointerEvents"> & {
33
33
  insetEnd?: number;
34
34
  /** Explicit source; otherwise the nearest `ScrollSourceProvider` / the enclosing `Screen`. */
35
35
  scrollSource?: ScrollSource;
36
+ /**
37
+ * Lift the end fade above an open keyboard. A keyboard covers the
38
+ * scrollable without resizing it, so without this the end fade sits under
39
+ * the keyboard. Turn it on over a keyboard-aware scrollable with a sticky
40
+ * `Screen.Footer`; the fade rises by the same distance the footer does.
41
+ * @default false
42
+ */
43
+ keyboardAware?: boolean;
36
44
  /** Draw nothing without unmounting. @default true */
37
45
  enabled?: boolean;
38
46
  className?: string;
@@ -83,6 +91,7 @@ export function ScrollFade({
83
91
  insetStart = 0,
84
92
  insetEnd = 0,
85
93
  scrollSource,
94
+ keyboardAware = false,
86
95
  enabled = true,
87
96
  className,
88
97
  ...props
@@ -95,6 +104,8 @@ export function ScrollFade({
95
104
  // Without a colour to fade from there is no honest gradient, and the keyword
96
105
  // `transparent` is no substitute — so the content renders alone.
97
106
  const canDraw = paint !== undefined && faded !== undefined;
107
+ // Only a keyboard-aware end reads the keyboard; see `ScrollFadeKeyboardEdge`.
108
+ const EndEdge = keyboardAware ? ScrollFadeKeyboardEdge : ScrollFadeEdge;
98
109
 
99
110
  return (
100
111
  <View className={scrollFadeVariants().root({ className })} {...props}>
@@ -111,7 +122,7 @@ export function ScrollFade({
111
122
  />
112
123
  ) : null}
113
124
  {canDraw && draw.end ? (
114
- <ScrollFadeEdge
125
+ <EndEdge
115
126
  color={paint}
116
127
  edge="end"
117
128
  fadeInDistance={fadeInDistance}
@@ -91,8 +91,7 @@ export function ScrollHeaderRoot({
91
91
  if (target === null) return;
92
92
  scrollTo(scrollRef, 0, target, !isCalm);
93
93
  snapTarget.value = null;
94
- },
95
- [isCalm, scrollRef]
94
+ }
96
95
  );
97
96
 
98
97
  const handleCollapsedChange = useCallback(
@@ -111,16 +110,14 @@ export function ScrollHeaderRoot({
111
110
  if (next === previous) return;
112
111
  if (previous === null && !next) return;
113
112
  scheduleOnRN(handleCollapsedChange, next);
114
- },
115
- [handleCollapsedChange]
113
+ }
116
114
  );
117
115
 
118
116
  useAnimatedReaction(
119
117
  () => progress.value,
120
118
  (value) => {
121
119
  if (progressOutput) progressOutput.value = value;
122
- },
123
- [progressOutput]
120
+ }
124
121
  );
125
122
 
126
123
  const registerLarge = useCallback(() => {
@@ -42,14 +42,11 @@ export function useScrollHeaderScrollProps(
42
42
  const forward = callerFunction ?? noop;
43
43
  const hasFunction = callerFunction !== null;
44
44
 
45
- const forwarder = useAnimatedScrollHandler(
46
- {
47
- onScroll: (event) => {
48
- if (hasFunction) scheduleOnRN(forward, event as NativeScrollEvent);
49
- },
45
+ const forwarder = useAnimatedScrollHandler({
46
+ onScroll: (event) => {
47
+ if (hasFunction) scheduleOnRN(forward, event as NativeScrollEvent);
50
48
  },
51
- [hasFunction, forward]
52
- );
49
+ });
53
50
 
54
51
  const composed = useComposedEventHandler([scrollProps.onScroll, callerHandler, hasFunction ? forwarder : null]);
55
52
 
@@ -93,8 +93,7 @@ export function useScrollTextProgress({
93
93
  },
94
94
  (next) => {
95
95
  if (next >= 0) own.value = next;
96
- },
97
- [isLive, start, end, windowHeight, viewport]
96
+ }
98
97
  );
99
98
 
100
99
  const remeasure = (): void => {
@@ -169,8 +169,7 @@ function SectionProgressRoot(props: SectionProgressProps): ReactElement {
169
169
  } else {
170
170
  ringProgress.value = withTiming(next, RING_SETTLE);
171
171
  }
172
- },
173
- [source]
172
+ }
174
173
  );
175
174
 
176
175
  const scrollY = scroll?.scrollY;
@@ -179,8 +178,7 @@ function SectionProgressRoot(props: SectionProgressProps): ReactElement {
179
178
  () => (scrollY === undefined ? true : resolveRevealed(scrollY.value, revealAt, false)),
180
179
  (now, was) => {
181
180
  if (now && was !== true) scheduleOnRN(setRevealed, true);
182
- },
183
- [scrollY, revealAt]
181
+ }
184
182
  );
185
183
 
186
184
  // The percent only crosses to React while a screen reader is listening; with
@@ -196,8 +194,7 @@ function SectionProgressRoot(props: SectionProgressProps): ReactElement {
196
194
  () => (isScreenReaderOn ? resolveProgressPercent(source.value) : -1),
197
195
  (next, previous) => {
198
196
  if (next >= 0 && next !== previous) scheduleOnRN(setPercent, next);
199
- },
200
- [isScreenReaderOn, source]
197
+ }
201
198
  );
202
199
 
203
200
  const lastValue = useRef(value);
@@ -214,8 +214,7 @@ export function SliderTrack({ children, className, ...props }: SliderTrackProps)
214
214
  if (!changed) return;
215
215
  }
216
216
  scheduleOnRN(commitValues, [...current]);
217
- },
218
- [commitValues]
217
+ }
219
218
  );
220
219
 
221
220
  const slots = sliderVariants({ color, isDisabled, isInvalid, orientation, size });
@@ -77,8 +77,7 @@ export function SortableItem({
77
77
  () => activeId.value === id,
78
78
  (next, previous) => {
79
79
  if (previous !== null && next !== previous) scheduleOnRN(setLifted, next);
80
- },
81
- [id]
80
+ }
82
81
  );
83
82
 
84
83
  const shift = useSharedValue(0);
@@ -88,8 +87,7 @@ export function SortableItem({
88
87
  if (next === previous) return;
89
88
  if (phase.value === 0 || isReducedMotion) shift.value = next;
90
89
  else shift.value = withSpring(next, SORTABLE_SHIFT_SPRING);
91
- },
92
- [id, isReducedMotion]
90
+ }
93
91
  );
94
92
 
95
93
  const animatedStyle = useAnimatedStyle(() => {
@@ -144,8 +144,7 @@ export function TabsScrollView({
144
144
  // on top of it would be two easing curves fighting for the same pixels.
145
145
  false
146
146
  );
147
- },
148
- [scrollAlign]
147
+ }
149
148
  );
150
149
 
151
150
  const slots = tabsVariants({ isScrollable: true, size, variant });
@@ -54,10 +54,7 @@ export function TabsSeparator({
54
54
  );
55
55
  }
56
56
 
57
- const fadeStyle = useAnimatedStyle(
58
- () => ({ opacity: resolveSeparatorOpacity(position.value, left, right) }),
59
- [left, right]
60
- );
57
+ const fadeStyle = useAnimatedStyle(() => ({ opacity: resolveSeparatorOpacity(position.value, left, right) }));
61
58
 
62
59
  const slots = tabsVariants({ size, variant });
63
60
 
@@ -274,8 +274,7 @@ function TabsRoot({
274
274
  if (current === previous) return;
275
275
  visual.value = current;
276
276
  scheduleOnRN(setVisualIndex, current);
277
- },
278
- [count]
277
+ }
279
278
  );
280
279
 
281
280
  const context = useMemo<TabsContextValue>(
@@ -216,8 +216,7 @@ export function TextAnimationTyping({
216
216
  if (previous !== null && previous.phase === "typing" && current.phase !== "typing") {
217
217
  scheduleOnRN(handleTyped, previous.phraseIndex, runKey);
218
218
  }
219
- },
220
- [runKey, handlePhrase, handleTyped]
219
+ }
221
220
  );
222
221
 
223
222
  useEffect(() => {
@@ -72,8 +72,7 @@ function ReadingEdgeTitle({
72
72
  const isFirstRun = previous === null;
73
73
  progress.value =
74
74
  isFirstRun || isReducedMotion ? target : withTiming(target, { duration: TIMELINE_READING_EDGE_MS });
75
- },
76
- [step, isReducedMotion]
75
+ }
77
76
  );
78
77
 
79
78
  const colorStyle = useAnimatedStyle(() => ({
@@ -37,18 +37,15 @@ export function useTimelineReadingEdge({
37
37
  [onColumnChange]
38
38
  );
39
39
 
40
- const scrollHandler = useAnimatedScrollHandler(
41
- {
42
- onScroll: (event) => {
43
- const next = resolveColumnAtOffset(offsets, event.contentOffset.x);
44
- if (next === activeColumn.value) return;
45
- activeColumn.value = next;
46
- if (haptic) playHaptic("selection");
47
- if (hasListener) scheduleOnRN(notify, next);
48
- },
40
+ const scrollHandler = useAnimatedScrollHandler({
41
+ onScroll: (event) => {
42
+ const next = resolveColumnAtOffset(offsets, event.contentOffset.x);
43
+ if (next === activeColumn.value) return;
44
+ activeColumn.value = next;
45
+ if (haptic) playHaptic("selection");
46
+ if (hasListener) scheduleOnRN(notify, next);
49
47
  },
50
- [offsets, haptic, hasListener, notify]
51
- );
48
+ });
52
49
 
53
50
  return { activeColumn, scrollHandler };
54
51
  }
@@ -80,33 +80,30 @@ export function useScrollSource(options: UseScrollSourceOptions = {}): UseScroll
80
80
  const contentSize = useSharedValue(0);
81
81
  const layoutSize = useSharedValue(0);
82
82
 
83
- const onScroll = useAnimatedScrollHandler(
84
- {
85
- onScroll: (event) => {
86
- const reading = readScrollEvent(event, orientation);
87
- offset.value = reading.offset;
88
- if (reading.contentSize !== contentSize.value) contentSize.value = reading.contentSize;
89
- if (reading.layoutSize !== layoutSize.value) layoutSize.value = reading.layoutSize;
90
- },
91
- onBeginDrag: (event) => {
92
- offset.value = readScrollEvent(event, orientation).offset;
93
- onBeginDrag?.(event);
94
- },
95
- onEndDrag: (event) => {
96
- offset.value = readScrollEvent(event, orientation).offset;
97
- onEndDrag?.(event);
98
- },
99
- onMomentumBegin: (event) => {
100
- offset.value = readScrollEvent(event, orientation).offset;
101
- onMomentumBegin?.(event);
102
- },
103
- onMomentumEnd: (event) => {
104
- offset.value = readScrollEvent(event, orientation).offset;
105
- onMomentumEnd?.(event);
106
- },
83
+ const onScroll = useAnimatedScrollHandler({
84
+ onScroll: (event) => {
85
+ const reading = readScrollEvent(event, orientation);
86
+ offset.value = reading.offset;
87
+ if (reading.contentSize !== contentSize.value) contentSize.value = reading.contentSize;
88
+ if (reading.layoutSize !== layoutSize.value) layoutSize.value = reading.layoutSize;
107
89
  },
108
- [orientation, onBeginDrag, onEndDrag, onMomentumBegin, onMomentumEnd]
109
- );
90
+ onBeginDrag: (event) => {
91
+ offset.value = readScrollEvent(event, orientation).offset;
92
+ onBeginDrag?.(event);
93
+ },
94
+ onEndDrag: (event) => {
95
+ offset.value = readScrollEvent(event, orientation).offset;
96
+ onEndDrag?.(event);
97
+ },
98
+ onMomentumBegin: (event) => {
99
+ offset.value = readScrollEvent(event, orientation).offset;
100
+ onMomentumBegin?.(event);
101
+ },
102
+ onMomentumEnd: (event) => {
103
+ offset.value = readScrollEvent(event, orientation).offset;
104
+ onMomentumEnd?.(event);
105
+ },
106
+ });
110
107
 
111
108
  const onLayout = useCallback(
112
109
  (event: LayoutChangeEvent) => {
@@ -1,5 +1,12 @@
1
1
  import { describe, expect, test } from "bun:test";
2
- import { resolveFadeBand, resolveFadeEdges, resolveFadeGradient, SCROLL_EDGES, type ScrollEdges } from "./scroll-edges";
2
+ import {
3
+ resolveFadeBand,
4
+ resolveFadeEdges,
5
+ resolveFadeGradient,
6
+ resolveKeyboardLift,
7
+ SCROLL_EDGES,
8
+ type ScrollEdges,
9
+ } from "./scroll-edges";
3
10
 
4
11
  describe("resolveFadeEdges", () => {
5
12
  const EXPECTED: Record<ScrollEdges, { start: boolean; end: boolean }> = {
@@ -75,3 +82,31 @@ describe("resolveFadeGradient", () => {
75
82
  });
76
83
  });
77
84
  });
85
+
86
+ describe("resolveKeyboardLift", () => {
87
+ const open = { orientation: "vertical", edge: "end", safeAreaBottom: 34 } as const;
88
+
89
+ test("lifts a vertical end band by the keyboard less the safe-area band it covers", () => {
90
+ expect(resolveKeyboardLift({ ...open, keyboardHeight: 336 })).toBe(302);
91
+ });
92
+
93
+ test("reads keyboard-controller's negative height the same as a positive one", () => {
94
+ expect(resolveKeyboardLift({ ...open, keyboardHeight: -336 })).toBe(302);
95
+ });
96
+
97
+ test("does not lift with the keyboard closed", () => {
98
+ expect(resolveKeyboardLift({ ...open, keyboardHeight: 0 })).toBe(0);
99
+ });
100
+
101
+ test("never lifts below zero while the keyboard is shorter than the safe area", () => {
102
+ expect(resolveKeyboardLift({ ...open, keyboardHeight: 20 })).toBe(0);
103
+ });
104
+
105
+ test("leaves a start band in place", () => {
106
+ expect(resolveKeyboardLift({ ...open, edge: "start", keyboardHeight: 336 })).toBe(0);
107
+ });
108
+
109
+ test("leaves a horizontal band in place", () => {
110
+ expect(resolveKeyboardLift({ ...open, orientation: "horizontal", keyboardHeight: 336 })).toBe(0);
111
+ });
112
+ });
@@ -79,3 +79,26 @@ export function resolveFadeGradient({
79
79
 
80
80
  return { start: { x: near, y: 0 }, end: { x: far, y: 0 } };
81
81
  }
82
+
83
+ export type KeyboardLiftInput = {
84
+ orientation: ScrollOrientation;
85
+ edge: ScrollEdge;
86
+ /** The keyboard's height. keyboard-controller reports it negative while open; either sign reads the same. */
87
+ keyboardHeight: number;
88
+ /** The bottom safe-area inset, which an open keyboard covers. */
89
+ safeAreaBottom: number;
90
+ };
91
+
92
+ /**
93
+ * How far a band rises to stay above an open keyboard.
94
+ *
95
+ * A keyboard covers the scrollable without resizing it, so a band pinned to
96
+ * the bottom of its box ends up underneath. Only a vertical end band is
97
+ * there. It rises by the keyboard less the safe-area band, the same distance
98
+ * a sticky `Screen.Footer` moves, so the band stays on the footer's top edge.
99
+ */
100
+ export function resolveKeyboardLift({ orientation, edge, keyboardHeight, safeAreaBottom }: KeyboardLiftInput): number {
101
+ "worklet";
102
+ if (orientation !== "vertical" || edge !== "end") return 0;
103
+ return Math.max(0, Math.abs(keyboardHeight) - safeAreaBottom);
104
+ }
@@ -147,3 +147,30 @@ describe("readScrollEvent", () => {
147
147
  expect(readScrollEvent(bounce, "horizontal").offset).toBe(-40);
148
148
  });
149
149
  });
150
+
151
+ describe("scrollEdgeOpacity with a covered end", () => {
152
+ // A 600pt frame holding 500pt of form, with 300pt of keyboard over its end.
153
+ // KeyboardAwareScrollView makes room with a content inset, which the scroll
154
+ // event leaves out of contentSize.
155
+ const base = { edge: "end", contentSize: 500, layoutSize: 600, fadeInDistance: 24 } as const;
156
+
157
+ test("clears the end of content that fits the uncovered frame", () => {
158
+ expect(scrollEdgeOpacity({ ...base, offset: 0 })).toBe(0);
159
+ });
160
+
161
+ test("draws the end while content runs on under the covered band", () => {
162
+ expect(scrollEdgeOpacity({ ...base, offset: 0, coveredEnd: 300 })).toBe(1);
163
+ });
164
+
165
+ test("clears the end once the last content is scrolled above the covered band", () => {
166
+ expect(scrollEdgeOpacity({ ...base, offset: 200, coveredEnd: 300 })).toBe(0);
167
+ });
168
+
169
+ test("fades the end over the last fadeInDistance points above the covered band", () => {
170
+ expect(scrollEdgeOpacity({ ...base, offset: 188, coveredEnd: 300 })).toBe(0.5);
171
+ });
172
+
173
+ test("leaves the start edge reading the offset alone", () => {
174
+ expect(scrollEdgeOpacity({ ...base, edge: "start", offset: 12, coveredEnd: 300 })).toBe(0.5);
175
+ });
176
+ });
@@ -59,6 +59,14 @@ export type ScrollEdgeState = {
59
59
  layoutSize: number;
60
60
  /** Points of travel from fully clear to fully in. 0 makes it a step. */
61
61
  fadeInDistance: number;
62
+ /**
63
+ * Points at the end of the frame covered by something drawn over it — an
64
+ * open keyboard. `KeyboardAwareScrollView` makes room for the keyboard with
65
+ * a content inset, which the scroll event leaves out of `contentSize`, so
66
+ * content that fits the whole frame can still run on under the cover.
67
+ * @default 0
68
+ */
69
+ coveredEnd?: number;
62
70
  };
63
71
 
64
72
  /**
@@ -77,9 +85,10 @@ export type ScrollEdgeState = {
77
85
  */
78
86
  export function scrollEdgeOpacity(state: ScrollEdgeState): number {
79
87
  "worklet";
80
- const { edge, offset, contentSize, layoutSize, fadeInDistance } = state;
88
+ const { edge, offset, contentSize, layoutSize, fadeInDistance, coveredEnd = 0 } = state;
81
89
  const isMeasured = contentSize > 0 && layoutSize > 0;
82
- const maxScroll = contentSize - layoutSize;
90
+ // Measured against the part of the frame that is still visible.
91
+ const maxScroll = contentSize - (layoutSize - coveredEnd);
83
92
 
84
93
  if (isMeasured && maxScroll <= 0) return 0;
85
94