react-native-terra-ui 0.11.6 → 0.11.7

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 (41) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/lib/module/components/header/parts/HeaderAccessory.js +25 -22
  3. package/lib/module/components/header/parts/HeaderAccessory.js.map +1 -1
  4. package/lib/module/components/header/parts/HeaderSurface.js +13 -20
  5. package/lib/module/components/header/parts/HeaderSurface.js.map +1 -1
  6. package/lib/module/components/header/variants/LargeTitleHeader/index.js +106 -40
  7. package/lib/module/components/header/variants/LargeTitleHeader/index.js.map +1 -1
  8. package/lib/module/components/header/variants/TitleHeader/index.js +5 -1
  9. package/lib/module/components/header/variants/TitleHeader/index.js.map +1 -1
  10. package/lib/module/components/screen/ScreenContext.js +7 -1
  11. package/lib/module/components/screen/ScreenContext.js.map +1 -1
  12. package/lib/module/components/screen/ScreenList.js +19 -2
  13. package/lib/module/components/screen/ScreenList.js.map +1 -1
  14. package/lib/module/components/screen/ScreenScrollView.js +5 -1
  15. package/lib/module/components/screen/ScreenScrollView.js.map +1 -1
  16. package/lib/typescript/src/components/header/index.d.ts +1 -1
  17. package/lib/typescript/src/components/header/index.d.ts.map +1 -1
  18. package/lib/typescript/src/components/header/parts/HeaderAccessory.d.ts +11 -11
  19. package/lib/typescript/src/components/header/parts/HeaderAccessory.d.ts.map +1 -1
  20. package/lib/typescript/src/components/header/parts/HeaderSurface.d.ts +8 -10
  21. package/lib/typescript/src/components/header/parts/HeaderSurface.d.ts.map +1 -1
  22. package/lib/typescript/src/components/header/types.d.ts +11 -7
  23. package/lib/typescript/src/components/header/types.d.ts.map +1 -1
  24. package/lib/typescript/src/components/header/variants/LargeTitleHeader/index.d.ts +8 -1
  25. package/lib/typescript/src/components/header/variants/LargeTitleHeader/index.d.ts.map +1 -1
  26. package/lib/typescript/src/components/header/variants/TitleHeader/index.d.ts +3 -4
  27. package/lib/typescript/src/components/header/variants/TitleHeader/index.d.ts.map +1 -1
  28. package/lib/typescript/src/components/screen/ScreenContext.d.ts +7 -0
  29. package/lib/typescript/src/components/screen/ScreenContext.d.ts.map +1 -1
  30. package/lib/typescript/src/components/screen/ScreenList.d.ts.map +1 -1
  31. package/lib/typescript/src/components/screen/ScreenScrollView.d.ts.map +1 -1
  32. package/package.json +1 -1
  33. package/src/components/header/index.ts +1 -1
  34. package/src/components/header/parts/HeaderAccessory.tsx +21 -23
  35. package/src/components/header/parts/HeaderSurface.tsx +17 -20
  36. package/src/components/header/types.ts +12 -7
  37. package/src/components/header/variants/LargeTitleHeader/index.tsx +86 -15
  38. package/src/components/header/variants/TitleHeader/index.tsx +15 -6
  39. package/src/components/screen/ScreenContext.tsx +16 -0
  40. package/src/components/screen/ScreenList.tsx +22 -2
  41. package/src/components/screen/ScreenScrollView.tsx +16 -3
@@ -1,5 +1,5 @@
1
- import { useEffect } from "react";
2
- import { Text, View } from "react-native";
1
+ import { useCallback, useEffect, useState } from "react";
2
+ import { type LayoutChangeEvent, Text, View } from "react-native";
3
3
 
4
4
  import Animated, { interpolate, useAnimatedStyle, useSharedValue } from "react-native-reanimated";
5
5
  import { StyleSheet, useUnistyles } from "react-native-unistyles";
@@ -11,8 +11,10 @@ import { useHeaderBackground } from "../../../screen/parts/ScreenHeaderAppearanc
11
11
  import { SCREEN_HEADER_PORTAL_HOST, useScreen } from "../../../screen/ScreenContext";
12
12
  import { Toolbar } from "../../../toolbar";
13
13
  import { HeaderDismissButton } from "../../header-buttons";
14
- import { HeaderAccessory } from "../../parts/HeaderAccessory";
14
+ import { HeaderAccessory, HeaderAccessorySpacer } from "../../parts/HeaderAccessory";
15
+ import { HEADER_FADE_EXTENT } from "../../parts/HeaderGradientOverlay";
15
16
  import { HeaderSurface } from "../../parts/HeaderSurface";
17
+ import type { HeaderAccessoryBehavior } from "../../types";
16
18
  import { isCustomTitle, isTextTitle } from "../../utils";
17
19
  import type { TitleHeaderProps } from "../TitleHeader";
18
20
 
@@ -30,6 +32,12 @@ export interface LargeTitleHeaderProps extends TitleHeaderProps {
30
32
  subtitle?: string;
31
33
  /** Hide the large title (keeps the compact bar). Defaults to `false`. */
32
34
  isLargeTitleHidden?: boolean;
35
+ /**
36
+ * How `accessory` moves while scrolling. `'sticky'` starts below the large title
37
+ * and pins below the compact bar when scrolled up. A hidden large title always pins.
38
+ * @defaultValue 'pinned'
39
+ */
40
+ accessoryBehavior?: HeaderAccessoryBehavior;
33
41
  /**
34
42
  * Placement of the compact bar title. The large title stack always stays
35
43
  * left-aligned. Defaults to `'center'`.
@@ -41,7 +49,11 @@ function LargeTitlePortalContent({
41
49
  title,
42
50
  overline,
43
51
  subtitle,
44
- }: Pick<LargeTitleHeaderProps, "title" | "overline" | "subtitle">) {
52
+ onHeightChange,
53
+ }: Pick<LargeTitleHeaderProps, "title" | "overline" | "subtitle"> & {
54
+ /** Reports the large-title block's layout height in dp. */
55
+ onHeightChange?: (height: number) => void;
56
+ }) {
45
57
  const { theme } = useUnistyles();
46
58
  const { scrollY } = useScreen();
47
59
 
@@ -63,8 +75,11 @@ function LargeTitlePortalContent({
63
75
  };
64
76
  });
65
77
 
78
+ // Layout height excludes the collapse transform.
79
+ const handleLayout = (event: LayoutChangeEvent) => onHeightChange?.(event.nativeEvent.layout.height);
80
+
66
81
  return (
67
- <Animated.View style={[styles.portalContent, largeTitleAnimatedStyle]}>
82
+ <Animated.View style={[styles.portalContent, largeTitleAnimatedStyle]} onLayout={handleLayout}>
68
83
  {!!overline && (
69
84
  <Text
70
85
  style={styles.overline}
@@ -126,6 +141,7 @@ export function LargeTitleHeader({
126
141
  RightToolbar,
127
142
  accessory,
128
143
  accessoryPadding = "x",
144
+ accessoryBehavior = "pinned",
129
145
  dismissAction = "none",
130
146
  navigation,
131
147
  onDismiss,
@@ -136,7 +152,7 @@ export function LargeTitleHeader({
136
152
  }: LargeTitleHeaderProps) {
137
153
  const { theme } = useUnistyles();
138
154
 
139
- const { scrollY, background: screenBackground } = useScreen();
155
+ const { scrollY, background: screenBackground, setHeaderAccessoryHeight, contentTopPadding } = useScreen();
140
156
  const colorToken = background ?? bg;
141
157
  const bgColor =
142
158
  (colorToken ? resolveThemeColor(colorToken, theme) : screenBackground) ?? theme.color["surface.canvas"];
@@ -144,7 +160,39 @@ export function LargeTitleHeader({
144
160
  const trailingContent = trailingActions !== undefined ? trailingActions : RightToolbar;
145
161
 
146
162
  const gradientColor = bgColor;
163
+ const isSticky = accessoryBehavior === "sticky" && !isLargeTitleHidden;
164
+ // Sticky accessories reserve space below the large title; pinned ones add top padding.
165
+ const [stickyAccessoryHeight, setStickyAccessoryHeight] = useState(0);
166
+ const [largeTitleHeight, setLargeTitleHeight] = useState(0);
147
167
  const isLargeTitleHiddenValue = useSharedValue(isLargeTitleHidden);
168
+
169
+ const handleAccessoryHeightChange = useCallback(
170
+ (height: number) => {
171
+ if (isSticky) setStickyAccessoryHeight(Math.max(0, Math.round(height)));
172
+ else setHeaderAccessoryHeight(height);
173
+ },
174
+ [isSticky, setHeaderAccessoryHeight]
175
+ );
176
+
177
+ const handleLargeTitleHeightChange = useCallback((height: number) => {
178
+ setLargeTitleHeight(Math.max(0, Math.round(height)));
179
+ }, []);
180
+
181
+ // Rest below the title, including the scroll container's top padding.
182
+ const stickyAccessoryOffset = Math.max(
183
+ 0,
184
+ contentTopPadding + largeTitleHeight - theme.layout.header.height
185
+ );
186
+
187
+ // Zero translation places the accessory directly below the compact bar.
188
+ const stickyAccessoryStyle = useAnimatedStyle(() => {
189
+ if (isLargeTitleHiddenValue.value) {
190
+ return { transform: [{ translateY: 0 }] };
191
+ }
192
+ return {
193
+ transform: [{ translateY: Math.max(0, stickyAccessoryOffset - scrollY.value) }],
194
+ };
195
+ });
148
196
  const headerCollapseHeight = theme.layout.header.height;
149
197
  styles.useVariants({ titleAlignment });
150
198
 
@@ -212,14 +260,21 @@ export function LargeTitleHeader({
212
260
  );
213
261
 
214
262
  return (
215
- <View style={styles.container}>
263
+ // Let touches in the expanded header's empty space reach the scroll content.
264
+ <View style={styles.container(isSticky ? stickyAccessoryOffset : 0)} pointerEvents="box-none">
216
265
  {!isLargeTitleHidden && (
217
266
  <Portal hostName={SCREEN_HEADER_PORTAL_HOST}>
218
- <LargeTitlePortalContent title={title} overline={overline} subtitle={subtitle} />
267
+ <LargeTitlePortalContent
268
+ title={title}
269
+ overline={overline}
270
+ subtitle={subtitle}
271
+ onHeightChange={isSticky ? handleLargeTitleHeightChange : undefined}
272
+ />
273
+ {/* Reserve space for the accessory rendered in the overlay. */}
274
+ {isSticky && accessory != null && <HeaderAccessorySpacer height={stickyAccessoryHeight} />}
219
275
  </Portal>
220
276
  )}
221
- {/* The fade covers the bar and any accessory as one surface, so content
222
- scrolling under the header meets a single boundary rather than two. */}
277
+ {/* Share a background between the bar and a pinned accessory. */}
223
278
  <View style={styles.barGroup}>
224
279
  <HeaderSurface color={gradientColor} animatedStyle={gradientOpacityStyle} />
225
280
  <View style={styles.bar}>
@@ -243,21 +298,37 @@ export function LargeTitleHeader({
243
298
  </Animated.View>
244
299
  {trailing != null && <Toolbar style={styles.trailing}>{trailing}</Toolbar>}
245
300
  </View>
246
- {/* Pinned, so it stays legible while the large title collapses past it. */}
247
- {accessory != null && <HeaderAccessory padding={accessoryPadding}>{accessory}</HeaderAccessory>}
301
+ {accessory != null && !isSticky && (
302
+ <HeaderAccessory padding={accessoryPadding} onHeightChange={handleAccessoryHeightChange}>
303
+ {accessory}
304
+ </HeaderAccessory>
305
+ )}
248
306
  </View>
307
+ {accessory != null && isSticky && (
308
+ // The background moves with the sticky accessory.
309
+ <Animated.View style={[styles.stickyAccessory, stickyAccessoryStyle]}>
310
+ <HeaderSurface color={gradientColor} fadeHeight={HEADER_FADE_EXTENT} />
311
+ <HeaderAccessory padding={accessoryPadding} onHeightChange={handleAccessoryHeightChange}>
312
+ {accessory}
313
+ </HeaderAccessory>
314
+ </Animated.View>
315
+ )}
249
316
  </View>
250
317
  );
251
318
  }
252
319
 
253
320
  const styles = StyleSheet.create((theme) => ({
254
- container: {
321
+ // Include the translated accessory in the parent's touchable bounds.
322
+ container: (accessoryOffset: number) => ({
255
323
  position: "absolute",
256
324
  top: 0,
257
325
  left: 0,
258
326
  right: 0,
259
327
  zIndex: 1000,
260
- },
328
+ paddingBottom: accessoryOffset,
329
+ }),
330
+ // Normal flow places the accessory directly below the bar.
331
+ stickyAccessory: { position: "relative" },
261
332
  // Anchors the absolutely positioned surface over the bar and the accessory both.
262
333
  barGroup: { position: "relative" },
263
334
  bar: {
@@ -283,7 +354,7 @@ const styles = StyleSheet.create((theme) => ({
283
354
  portalContent: {
284
355
  gap: 2,
285
356
  minHeight: theme.layout.header.height,
286
- paddingBottom: theme.spacing["3"],
357
+ paddingBottom: theme.spacing["1"],
287
358
  paddingHorizontal: theme.layout.screen.padding.x,
288
359
  },
289
360
  overline: {
@@ -1,4 +1,4 @@
1
- import { type ReactNode, useContext } from "react";
1
+ import { type ReactNode, useCallback, useContext } from "react";
2
2
  import { Text, View } from "react-native";
3
3
 
4
4
  import { StyleSheet, useUnistyles } from "react-native-unistyles";
@@ -37,10 +37,9 @@ export interface TitleHeaderProps extends HeaderDismissProps {
37
37
  /** @deprecated Use trailingActions. */
38
38
  RightToolbar?: ReactNode;
39
39
  /**
40
- * Content pinned directly below the bar — a search field, a scrollable tab
41
- * strip, a filter row. It stays put while the screen scrolls, and the
42
- * surrounding `Screen`'s scroll containers reserve its measured height so
43
- * content starts below it rather than under it.
40
+ * Content below the header, such as a search field or tab strip.
41
+ * `Header.Title` always pins it below the bar. `Header.LargeTitle` also supports
42
+ * scrolling it with the large title via `accessoryBehavior`.
44
43
  */
45
44
  accessory?: ReactNode;
46
45
  /**
@@ -98,6 +97,12 @@ export function TitleHeader({
98
97
  useHeaderBackground(bgColor);
99
98
  const leadingContent = leadingActions !== undefined ? leadingActions : LeftToolbar;
100
99
  const trailingContent = trailingActions !== undefined ? trailingActions : RightToolbar;
100
+ const setAccessoryInset = screen?.setHeaderAccessoryHeight;
101
+ // Compact headers always reserve the accessory height as top padding.
102
+ const handleAccessoryHeightChange = useCallback(
103
+ (height: number) => setAccessoryInset?.(height),
104
+ [setAccessoryInset]
105
+ );
101
106
  const isTransparent = bgColor === "transparent";
102
107
  const gradientColor = bgColor;
103
108
  styles.useVariants({ titleAlignment });
@@ -139,7 +144,11 @@ export function TitleHeader({
139
144
  </View>
140
145
  {trailing != null && <Toolbar style={styles.trailing}>{trailing}</Toolbar>}
141
146
  </View>
142
- {accessory != null && <HeaderAccessory padding={accessoryPadding}>{accessory}</HeaderAccessory>}
147
+ {accessory != null && (
148
+ <HeaderAccessory padding={accessoryPadding} onHeightChange={handleAccessoryHeightChange}>
149
+ {accessory}
150
+ </HeaderAccessory>
151
+ )}
143
152
  </View>
144
153
  </View>
145
154
  );
@@ -78,6 +78,13 @@ export interface ScreenContextValue {
78
78
  * accessory; zero releases the reserved space.
79
79
  */
80
80
  setHeaderAccessoryHeight: (height: number) => void;
81
+ /**
82
+ * Primary scroll container's resolved top padding in dp, including header clearance.
83
+ * Used to position a sticky accessory below the large title at scroll offset zero.
84
+ */
85
+ contentTopPadding: number;
86
+ /** Reported by the primary scroll container from its layout or resolved padding. */
87
+ setContentTopPadding: (padding: number) => void;
81
88
  /** Vertical scroll offset, updated on the UI thread for header animations. */
82
89
  scrollY: SharedValue<number>;
83
90
  /**
@@ -142,6 +149,7 @@ export function ScreenScrollProvider({
142
149
  }: ScreenScrollProviderProps) {
143
150
  const scrollY = useSharedValue(0);
144
151
  const [headerAccessoryHeight, setAccessoryHeight] = useState(0);
152
+ const [contentTopPadding, setTopPadding] = useState(0);
145
153
  const headerCollapseHeight = useSharedValue(0);
146
154
  const [headerSnapOffsets, setHeaderSnapOffsets] = useState<number[]>();
147
155
  const scrollRef = useAnimatedRef<Animated.ScrollView>();
@@ -161,6 +169,10 @@ export function ScreenScrollProvider({
161
169
  setAccessoryHeight(Math.max(0, Math.round(height)));
162
170
  }, []);
163
171
 
172
+ const setContentTopPadding = useCallback((padding: number) => {
173
+ setTopPadding(Math.max(0, Math.round(padding)));
174
+ }, []);
175
+
164
176
  const scrollHandler = useAnimatedScrollHandler({
165
177
  onScroll: (event) => {
166
178
  scrollY.value = event.contentOffset.y;
@@ -179,6 +191,8 @@ export function ScreenScrollProvider({
179
191
  hasHeader,
180
192
  headerAccessoryHeight,
181
193
  setHeaderAccessoryHeight,
194
+ contentTopPadding,
195
+ setContentTopPadding,
182
196
  scrollY,
183
197
  headerCollapseHeight,
184
198
  headerSnapOffsets,
@@ -196,6 +210,8 @@ export function ScreenScrollProvider({
196
210
  hasHeader,
197
211
  headerAccessoryHeight,
198
212
  setHeaderAccessoryHeight,
213
+ contentTopPadding,
214
+ setContentTopPadding,
199
215
  scrollY,
200
216
  headerCollapseHeight,
201
217
  headerSnapOffsets,
@@ -7,12 +7,19 @@ import {
7
7
  type ReactElement,
8
8
  type Ref,
9
9
  type RefAttributes,
10
+ useEffect,
10
11
  useMemo,
11
12
  } from "react";
12
- import { FlatList, type FlatListProps, type ListRenderItem, View } from "react-native";
13
+ import {
14
+ FlatList,
15
+ type FlatListProps,
16
+ type ListRenderItem,
17
+ StyleSheet as NativeStyleSheet,
18
+ View,
19
+ } from "react-native";
13
20
 
14
21
  import Animated from "react-native-reanimated";
15
- import { StyleSheet } from "react-native-unistyles";
22
+ import { StyleSheet, useUnistyles } from "react-native-unistyles";
16
23
 
17
24
  import { resolveScreenPadding } from "#theme/screen-padding";
18
25
  import { PortalHost } from "../portal";
@@ -175,6 +182,7 @@ const ScreenListBase = forwardRef(function ScreenList(
175
182
  contentPadding: screenPadding,
176
183
  hasHeader,
177
184
  headerAccessoryHeight,
185
+ setContentTopPadding,
178
186
  contentOwnsBottomInset,
179
187
  platformAdjustsInsets,
180
188
  } = useScreen();
@@ -184,6 +192,18 @@ const ScreenListBase = forwardRef(function ScreenList(
184
192
  useNestedContainerWarning("Screen.List", horizontal);
185
193
  const mergedRef = useScreenRef(isDefaultFlatList ? scrollRef : undefined, ref);
186
194
  const resolvedPadding = contentPadding ?? margins ?? screenPadding;
195
+ const { theme, rt } = useUnistyles();
196
+ const { y: paddingY } = resolveScreenPadding(theme, rt.breakpoint);
197
+ const defaultTopPadding =
198
+ (hasHeader ? theme.layout.header.height + headerAccessoryHeight : 0) +
199
+ (hasVerticalPadding(resolvedPadding) ? paddingY : 0);
200
+ const paddingTopOverride = NativeStyleSheet.flatten(contentContainerStyle)?.paddingTop;
201
+ const contentTopPadding = typeof paddingTopOverride === "number" ? paddingTopOverride : defaultTopPadding;
202
+ // ListHeaderComponent is wrapped by the list, so its local layout.y excludes
203
+ // content padding. Report the resolved padding, including caller overrides.
204
+ useEffect(() => {
205
+ if (!horizontal) setContentTopPadding(contentTopPadding);
206
+ }, [contentTopPadding, horizontal, setContentTopPadding]);
187
207
  const resolvedLoading = isLoading ?? loading;
188
208
  const placeholder = renderScreenPlaceholder({
189
209
  isLoading: resolvedLoading,
@@ -1,5 +1,12 @@
1
- import { Children, type ComponentRef, forwardRef, type ReactNode } from "react";
2
- import { type ScrollView, type ScrollViewProps, type StyleProp, View, type ViewStyle } from "react-native";
1
+ import { Children, type ComponentRef, forwardRef, type ReactNode, useCallback } from "react";
2
+ import {
3
+ type LayoutChangeEvent,
4
+ type ScrollView,
5
+ type ScrollViewProps,
6
+ type StyleProp,
7
+ View,
8
+ type ViewStyle,
9
+ } from "react-native";
3
10
 
4
11
  import Animated from "react-native-reanimated";
5
12
  import { StyleSheet } from "react-native-unistyles";
@@ -72,6 +79,7 @@ export const ScreenScrollView = forwardRef<ComponentRef<typeof ScrollView>, Scre
72
79
  contentPadding: screenPadding,
73
80
  hasHeader,
74
81
  headerAccessoryHeight,
82
+ setContentTopPadding,
75
83
  background,
76
84
  contentOwnsBottomInset,
77
85
  platformAdjustsInsets,
@@ -89,6 +97,11 @@ export const ScreenScrollView = forwardRef<ComponentRef<typeof ScrollView>, Scre
89
97
  isEmpty: isEmpty ?? Children.toArray(children).length === 0,
90
98
  });
91
99
  const composedScrollHandler = useScreenScrollHandler(scrollHandler, onScroll, externalScrollHandler);
100
+ // As the first child, the header wrapper's layout.y includes resolved top padding.
101
+ const handleHeaderWrapperLayout = useCallback(
102
+ (event: LayoutChangeEvent) => setContentTopPadding(event.nativeEvent.layout.y),
103
+ [setContentTopPadding]
104
+ );
92
105
 
93
106
  return (
94
107
  <Animated.ScrollView
@@ -112,7 +125,7 @@ export const ScreenScrollView = forwardRef<ComponentRef<typeof ScrollView>, Scre
112
125
  {...rest}
113
126
  onScroll={composedScrollHandler}
114
127
  >
115
- <View style={styles.headerWrapper(resolvedPadding)}>
128
+ <View style={styles.headerWrapper(resolvedPadding)} onLayout={handleHeaderWrapperLayout}>
116
129
  <PortalHost name={SCREEN_HEADER_PORTAL_HOST} />
117
130
  </View>
118
131
  <View style={[styles.content(horizontal), contentStyle]}>{placeholder ?? children}</View>