react-native-terra-ui 0.11.11 → 0.11.12

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 (51) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/lib/module/components/header/hooks/use-header-color.js +16 -0
  3. package/lib/module/components/header/hooks/use-header-color.js.map +1 -0
  4. package/lib/module/components/header/hooks/use-large-title-header.js +81 -0
  5. package/lib/module/components/header/hooks/use-large-title-header.js.map +1 -0
  6. package/lib/module/components/header/parts/HeaderAccessory.js +21 -6
  7. package/lib/module/components/header/parts/HeaderAccessory.js.map +1 -1
  8. package/lib/module/components/header/parts/HeaderBar.js +122 -0
  9. package/lib/module/components/header/parts/HeaderBar.js.map +1 -0
  10. package/lib/module/components/header/parts/HeaderGradientOverlay.js +5 -3
  11. package/lib/module/components/header/parts/HeaderGradientOverlay.js.map +1 -1
  12. package/lib/module/components/header/parts/HeaderSurface.js +8 -14
  13. package/lib/module/components/header/parts/HeaderSurface.js.map +1 -1
  14. package/lib/module/components/header/variants/LargeTitleHeader/index.js +51 -386
  15. package/lib/module/components/header/variants/LargeTitleHeader/index.js.map +1 -1
  16. package/lib/module/components/header/variants/LargeTitleHeader/parts/LargeTitleContent.js +72 -0
  17. package/lib/module/components/header/variants/LargeTitleHeader/parts/LargeTitleContent.js.map +1 -0
  18. package/lib/module/components/header/variants/TitleHeader/index.js +32 -160
  19. package/lib/module/components/header/variants/TitleHeader/index.js.map +1 -1
  20. package/lib/module/components/surface/Surface.js +2 -0
  21. package/lib/module/components/surface/Surface.js.map +1 -1
  22. package/lib/typescript/src/components/header/hooks/use-header-color.d.ts +4 -0
  23. package/lib/typescript/src/components/header/hooks/use-header-color.d.ts.map +1 -0
  24. package/lib/typescript/src/components/header/hooks/use-large-title-header.d.ts +36 -0
  25. package/lib/typescript/src/components/header/hooks/use-large-title-header.d.ts.map +1 -0
  26. package/lib/typescript/src/components/header/parts/HeaderAccessory.d.ts +8 -5
  27. package/lib/typescript/src/components/header/parts/HeaderAccessory.d.ts.map +1 -1
  28. package/lib/typescript/src/components/header/parts/HeaderBar.d.ts +17 -0
  29. package/lib/typescript/src/components/header/parts/HeaderBar.d.ts.map +1 -0
  30. package/lib/typescript/src/components/header/parts/HeaderGradientOverlay.d.ts +3 -1
  31. package/lib/typescript/src/components/header/parts/HeaderGradientOverlay.d.ts.map +1 -1
  32. package/lib/typescript/src/components/header/parts/HeaderSurface.d.ts +5 -9
  33. package/lib/typescript/src/components/header/parts/HeaderSurface.d.ts.map +1 -1
  34. package/lib/typescript/src/components/header/variants/LargeTitleHeader/index.d.ts +3 -23
  35. package/lib/typescript/src/components/header/variants/LargeTitleHeader/index.d.ts.map +1 -1
  36. package/lib/typescript/src/components/header/variants/LargeTitleHeader/parts/LargeTitleContent.d.ts +11 -0
  37. package/lib/typescript/src/components/header/variants/LargeTitleHeader/parts/LargeTitleContent.d.ts.map +1 -0
  38. package/lib/typescript/src/components/header/variants/TitleHeader/index.d.ts +3 -19
  39. package/lib/typescript/src/components/header/variants/TitleHeader/index.d.ts.map +1 -1
  40. package/lib/typescript/src/components/surface/Surface.d.ts.map +1 -1
  41. package/package.json +1 -1
  42. package/src/components/header/hooks/use-header-color.ts +14 -0
  43. package/src/components/header/hooks/use-large-title-header.ts +78 -0
  44. package/src/components/header/parts/HeaderAccessory.tsx +26 -7
  45. package/src/components/header/parts/HeaderBar.tsx +125 -0
  46. package/src/components/header/parts/HeaderGradientOverlay.tsx +9 -3
  47. package/src/components/header/parts/HeaderSurface.tsx +11 -17
  48. package/src/components/header/variants/LargeTitleHeader/index.tsx +44 -369
  49. package/src/components/header/variants/LargeTitleHeader/parts/LargeTitleContent.tsx +88 -0
  50. package/src/components/header/variants/TitleHeader/index.tsx +32 -146
  51. package/src/components/surface/Surface.tsx +2 -1
@@ -1,28 +1,17 @@
1
- import { useCallback, useEffect, useState } from "react";
2
- import { type LayoutChangeEvent, Text, View } from "react-native";
1
+ import { View } from "react-native";
3
2
 
4
- import Animated, { interpolate, useAnimatedStyle, useSharedValue } from "react-native-reanimated";
5
- import { StyleSheet, useUnistyles } from "react-native-unistyles";
3
+ import { StyleSheet } from "react-native-unistyles";
6
4
 
7
- import { resolveThemeColor } from "#utils/resolve-theme-color";
8
- import { FONT_WEIGHT_VALUE } from "#utils/typography";
9
5
  import { Portal } from "../../../portal";
10
6
  import { useHeaderBackground } from "../../../screen/parts/ScreenHeaderAppearance";
11
- import { SCREEN_HEADER_PORTAL_HOST, useScreen } from "../../../screen/ScreenContext";
12
- import { Toolbar } from "../../../toolbar";
13
- import { HeaderDismissButton } from "../../header-buttons";
7
+ import { SCREEN_HEADER_PORTAL_HOST } from "../../../screen/ScreenContext";
8
+ import { useHeaderColor } from "../../hooks/use-header-color";
9
+ import { useLargeTitleHeader } from "../../hooks/use-large-title-header";
14
10
  import { HeaderAccessory, HeaderAccessorySpacer } from "../../parts/HeaderAccessory";
15
- import { HeaderSurface } from "../../parts/HeaderSurface";
11
+ import { HeaderBar } from "../../parts/HeaderBar";
16
12
  import type { HeaderAccessoryBehavior } from "../../types";
17
- import { isCustomTitle, isTextTitle } from "../../utils";
18
13
  import type { TitleHeaderProps } from "../TitleHeader";
19
-
20
- const AnimatedText = Animated.createAnimatedComponent(Text);
21
-
22
- /** Compact title offset (px) when the large title is fully expanded. */
23
- const COMPACT_TITLE_SLIDE_OFFSET = 8;
24
- /** Large title offset (px) when fully collapsed. */
25
- const LARGE_TITLE_SLIDE_OFFSET = 6;
14
+ import { LargeTitleContent } from "./parts/LargeTitleContent";
26
15
 
27
16
  export interface LargeTitleHeaderProps extends TitleHeaderProps {
28
17
  /** Optional small line shown above the large title. */
@@ -34,7 +23,7 @@ export interface LargeTitleHeaderProps extends TitleHeaderProps {
34
23
  /**
35
24
  * How `accessory` moves while scrolling. `'sticky'` starts below the large title
36
25
  * and pins below the compact bar when scrolled up. A hidden large title always pins.
37
- * @defaultValue 'pinned'
26
+ * @defaultValue 'sticky'
38
27
  */
39
28
  accessoryBehavior?: HeaderAccessoryBehavior;
40
29
  /**
@@ -44,98 +33,7 @@ export interface LargeTitleHeaderProps extends TitleHeaderProps {
44
33
  titleAlignment?: "center" | "left";
45
34
  }
46
35
 
47
- function LargeTitlePortalContent({
48
- title,
49
- overline,
50
- subtitle,
51
- hasAccessoryBelow,
52
- onHeightChange,
53
- }: Pick<LargeTitleHeaderProps, "title" | "overline" | "subtitle"> & {
54
- /** Reserve a gap before a sticky accessory below this block. */
55
- hasAccessoryBelow: boolean;
56
- /** Reports the large-title block's layout height in dp. */
57
- onHeightChange?: (height: number) => void;
58
- }) {
59
- const { theme } = useUnistyles();
60
- const { scrollY } = useScreen();
61
-
62
- const headerCollapseHeight = theme.layout.header.height;
63
-
64
- const largeTitleAnimatedStyle = useAnimatedStyle(() => {
65
- const height = headerCollapseHeight;
66
- if (height <= 0) {
67
- return { opacity: 1, transform: [{ translateY: 0 }] };
68
- }
69
-
70
- return {
71
- opacity: interpolate(scrollY.value, [0, height], [1, 0], "clamp"),
72
- transform: [
73
- {
74
- translateY: interpolate(scrollY.value, [0, height], [0, -LARGE_TITLE_SLIDE_OFFSET], "clamp"),
75
- },
76
- ],
77
- };
78
- });
79
-
80
- // Layout height excludes the collapse transform.
81
- const handleLayout = (event: LayoutChangeEvent) => onHeightChange?.(event.nativeEvent.layout.height);
82
-
83
- return (
84
- <Animated.View
85
- style={[styles.portalContent(hasAccessoryBelow), largeTitleAnimatedStyle]}
86
- onLayout={handleLayout}
87
- >
88
- {!!overline && (
89
- <Text
90
- style={styles.overline}
91
- maxFontSizeMultiplier={theme.typography.variants["label-sm"].maxFontSizeMultiplier}
92
- >
93
- {overline}
94
- </Text>
95
- )}
96
- {isTextTitle(title) ? (
97
- <Text
98
- style={styles.largeTitle}
99
- maxFontSizeMultiplier={theme.typography.variants["headline-lg"].maxFontSizeMultiplier}
100
- >
101
- {title}
102
- </Text>
103
- ) : (
104
- title
105
- )}
106
- {!!subtitle && (
107
- <Text
108
- style={styles.subtitle}
109
- maxFontSizeMultiplier={theme.typography.variants["body-lg"].maxFontSizeMultiplier}
110
- >
111
- {subtitle}
112
- </Text>
113
- )}
114
- </Animated.View>
115
- );
116
- }
117
-
118
- /**
119
- * iOS-style collapsing large-title header. The large title is injected (via the
120
- * `Screen`'s portal) into the top of the scroll content, so it scrolls away
121
- * naturally; the compact title cross-fades over the theme header height.
122
- * The top safe area and bar overlay fade from transparent when expanded to
123
- * the header background when collapsed. The large-title content stays transparent.
124
- * Snapping is opt-in through the scroll container's snapToOffsets.
125
- *
126
- * Must be used inside a `Screen` with a `Screen.ScrollView` or `Screen.List`.
127
- *
128
- * @example
129
- * ```tsx
130
- * <Screen.Header>
131
- * <Header.LargeTitle
132
- * overline="Today"
133
- * title="Meditate"
134
- * subtitle="Good morning"
135
- * />
136
- * </Screen.Header>
137
- * ```
138
- */
36
+ /** Collapsing header. The expanded title lives in scroll content; the bar and accessory overlay it. */
139
37
  export function LargeTitleHeader({
140
38
  title,
141
39
  overline,
@@ -147,204 +45,61 @@ export function LargeTitleHeader({
147
45
  accessory,
148
46
  accessoryPadding = "x",
149
47
  accessoryBehavior = "sticky",
150
- dismissAction = "none",
151
- navigation,
152
- onDismiss,
153
48
  titleAlignment = "center",
154
49
  isLargeTitleHidden = false,
155
50
  background,
156
51
  bg,
52
+ ...dismissProps
157
53
  }: LargeTitleHeaderProps) {
158
- const { theme } = useUnistyles();
159
-
160
- const { scrollY, background: screenBackground, setHeaderAccessoryHeight, contentTopPadding } = useScreen();
161
- const colorToken = background ?? bg;
162
- const bgColor =
163
- (colorToken ? resolveThemeColor(colorToken, theme) : screenBackground) ?? theme.color["surface.canvas"];
164
- const leadingContent = leadingActions !== undefined ? leadingActions : LeftToolbar;
165
- const trailingContent = trailingActions !== undefined ? trailingActions : RightToolbar;
166
-
167
- const isSticky = accessoryBehavior === "sticky" && !isLargeTitleHidden;
168
- const hasStickyAccessory = isSticky && accessory != null;
169
- // Sticky accessories reserve space below the large title; pinned ones add top padding.
170
- const [stickyAccessoryHeight, setStickyAccessoryHeight] = useState(0);
171
- const [largeTitleHeight, setLargeTitleHeight] = useState(0);
172
- const isLargeTitleHiddenValue = useSharedValue(isLargeTitleHidden);
173
-
174
- const handleAccessoryHeightChange = useCallback(
175
- (height: number) => {
176
- if (isSticky) setStickyAccessoryHeight(Math.max(0, Math.round(height)));
177
- else setHeaderAccessoryHeight(height);
178
- },
179
- [isSticky, setHeaderAccessoryHeight]
180
- );
181
-
182
- const handleLargeTitleHeightChange = useCallback((height: number) => {
183
- setLargeTitleHeight(Math.max(0, Math.round(height)));
184
- }, []);
185
-
186
- // Rest below the title, including the scroll container's top padding.
187
- const stickyAccessoryOffset = Math.max(
188
- 0,
189
- contentTopPadding + largeTitleHeight - theme.layout.header.height
190
- );
191
-
192
- // Zero translation places the accessory directly below the compact bar.
193
- const stickyAccessoryStyle = useAnimatedStyle(() => {
194
- if (isLargeTitleHiddenValue.value) {
195
- return { transform: [{ translateY: 0 }] };
196
- }
197
- return {
198
- transform: [{ translateY: Math.max(0, stickyAccessoryOffset - scrollY.value) }],
199
- };
54
+ const color = useHeaderColor(background ?? bg);
55
+ const layout = useLargeTitleHeader({
56
+ hasAccessory: accessory != null,
57
+ accessoryBehavior,
58
+ isLargeTitleHidden,
200
59
  });
201
- const headerCollapseHeight = theme.layout.header.height;
202
- styles.useVariants({ titleAlignment });
203
-
204
- // Follow the accessory's bottom edge while keeping the background anchored to the bar.
205
- const stickySurfaceStyle = useAnimatedStyle(() => ({
206
- height: headerCollapseHeight + stickyAccessoryHeight + Math.max(0, stickyAccessoryOffset - scrollY.value),
207
- }));
208
-
209
- // Blend the accessory's resting surface into the shared header background.
210
- const accessorySurfaceStyle = useAnimatedStyle(() => ({
211
- opacity:
212
- stickyAccessoryOffset > 0 ? interpolate(scrollY.value, [0, stickyAccessoryOffset], [1, 0], "clamp") : 0,
213
- }));
214
-
215
- // Animate the header surface's opacity: 0 (expanded) → 1 (collapsed).
216
- const surfaceOpacityStyle = useAnimatedStyle(() => {
217
- if (isLargeTitleHiddenValue.value) {
218
- return { opacity: 1 };
219
- }
220
- const height = hasStickyAccessory ? stickyAccessoryOffset : headerCollapseHeight;
221
- if (height <= 0) {
222
- return { opacity: hasStickyAccessory ? 1 : 0 };
223
- }
224
- return {
225
- opacity: interpolate(scrollY.value, [0, height], [0, 1], "clamp"),
226
- };
227
- });
228
-
229
- useHeaderBackground(bgColor, surfaceOpacityStyle);
230
-
231
- const compactTitleAnimatedStyle = useAnimatedStyle(() => {
232
- if (isLargeTitleHiddenValue.value) {
233
- return { opacity: 1, transform: [{ translateY: 0 }] };
234
- }
235
-
236
- const height = headerCollapseHeight;
237
- if (height <= 0) {
238
- return {
239
- opacity: 0,
240
- transform: [{ translateY: COMPACT_TITLE_SLIDE_OFFSET }],
241
- };
242
- }
243
-
244
- return {
245
- opacity: interpolate(scrollY.value, [0, height], [0, 1], "clamp"),
246
- transform: [
247
- {
248
- translateY: interpolate(scrollY.value, [0, height], [COMPACT_TITLE_SLIDE_OFFSET, 0], "clamp"),
249
- },
250
- ],
251
- };
252
- });
253
-
254
- useEffect(() => {
255
- isLargeTitleHiddenValue.value = isLargeTitleHidden;
256
- }, [isLargeTitleHidden, isLargeTitleHiddenValue]);
257
-
258
- const leading =
259
- dismissAction === "back" ? (
260
- <>
261
- <HeaderDismissButton dismissAction={dismissAction} navigation={navigation} onDismiss={onDismiss} />
262
- {leadingContent}
263
- </>
264
- ) : (
265
- leadingContent
266
- );
267
-
268
- const trailing =
269
- dismissAction === "close" ? (
270
- <>
271
- {trailingContent}
272
- <HeaderDismissButton dismissAction={dismissAction} navigation={navigation} onDismiss={onDismiss} />
273
- </>
274
- ) : (
275
- trailingContent
276
- );
277
-
60
+ useHeaderBackground(color, layout.surfaceStyle);
278
61
  return (
279
- // Let touches in the expanded header's empty space reach the scroll content.
280
- <View style={styles.container(isSticky ? stickyAccessoryOffset : 0)} pointerEvents="box-none">
62
+ <View style={styles.container(layout.accessoryOffset)} pointerEvents="box-none">
281
63
  {!isLargeTitleHidden && (
282
64
  <Portal hostName={SCREEN_HEADER_PORTAL_HOST}>
283
- <LargeTitlePortalContent
65
+ <LargeTitleContent
284
66
  title={title}
285
67
  overline={overline}
286
68
  subtitle={subtitle}
287
- hasAccessoryBelow={hasStickyAccessory}
288
- onHeightChange={isSticky ? handleLargeTitleHeightChange : undefined}
69
+ hasAccessoryBelow={layout.isSticky}
70
+ onHeightChange={layout.setLargeTitleHeight}
71
+ animatedStyle={layout.largeTitleStyle}
289
72
  />
290
- {/* Reserve space for the accessory rendered in the overlay. */}
291
- {isSticky && accessory != null && <HeaderAccessorySpacer height={stickyAccessoryHeight} />}
73
+ {layout.isSticky && <HeaderAccessorySpacer height={layout.reservedAccessoryHeight} />}
292
74
  </Portal>
293
75
  )}
294
- {hasStickyAccessory && (
295
- <Animated.View style={[styles.stickySurface, stickySurfaceStyle]} pointerEvents="none">
296
- <HeaderSurface color={bgColor} animatedStyle={surfaceOpacityStyle} />
297
- </Animated.View>
298
- )}
299
- {/* Share a background between the bar and a pinned accessory. */}
300
- <View style={styles.barGroup}>
301
- {!hasStickyAccessory && <HeaderSurface color={bgColor} animatedStyle={surfaceOpacityStyle} />}
302
- <View style={styles.bar}>
303
- {leading != null && <Toolbar>{leading}</Toolbar>}
304
- {/* Text can never be the touch target, but custom content may be, so its
305
- container passes touches through to it instead of swallowing them. */}
306
- <Animated.View
307
- style={[styles.titleContainer, compactTitleAnimatedStyle]}
308
- pointerEvents={isCustomTitle(title) ? "box-none" : "none"}
309
- >
310
- {isTextTitle(title) ? (
311
- <AnimatedText
312
- style={styles.compactTitle}
313
- maxFontSizeMultiplier={theme.typography.variants["title-md"].maxFontSizeMultiplier}
314
- >
315
- {title}
316
- </AnimatedText>
317
- ) : (
318
- title
319
- )}
320
- </Animated.View>
321
- {trailing != null && <Toolbar style={styles.trailing}>{trailing}</Toolbar>}
322
- </View>
323
- {accessory != null && !isSticky && (
324
- <HeaderAccessory padding={accessoryPadding} onHeightChange={handleAccessoryHeightChange}>
325
- {accessory}
326
- </HeaderAccessory>
327
- )}
328
- </View>
329
- {accessory != null && isSticky && (
330
- <Animated.View style={[styles.stickyAccessory, stickyAccessoryStyle]}>
331
- <HeaderSurface
332
- color={screenBackground ?? theme.color["surface.canvas"]}
333
- animatedStyle={accessorySurfaceStyle}
334
- solid
335
- />
336
- <HeaderAccessory padding={accessoryPadding} onHeightChange={handleAccessoryHeightChange}>
337
- {accessory}
338
- </HeaderAccessory>
339
- </Animated.View>
76
+ <HeaderBar
77
+ {...dismissProps}
78
+ title={title}
79
+ titleAlignment={titleAlignment}
80
+ color={color}
81
+ surfaceStyle={layout.surfaceStyle}
82
+ titleStyle={layout.compactTitleStyle}
83
+ leading={leadingActions !== undefined ? leadingActions : LeftToolbar}
84
+ trailing={trailingActions !== undefined ? trailingActions : RightToolbar}
85
+ />
86
+ {accessory != null && (
87
+ <HeaderAccessory
88
+ padding={accessoryPadding}
89
+ color={color}
90
+ surfaceStyle={layout.surfaceStyle}
91
+ style={layout.accessoryStyle}
92
+ onHeightChange={layout.setAccessoryHeight}
93
+ >
94
+ {accessory}
95
+ </HeaderAccessory>
340
96
  )}
341
97
  </View>
342
98
  );
343
99
  }
344
100
 
345
- const styles = StyleSheet.create((theme) => ({
346
- stickySurface: { position: "absolute", top: 0, left: 0, right: 0 },
347
- // Include the translated accessory in the parent's touchable bounds.
101
+ const styles = StyleSheet.create(() => ({
102
+ // Keep the translated accessory inside the overlay's touchable bounds.
348
103
  container: (accessoryOffset: number) => ({
349
104
  position: "absolute",
350
105
  top: 0,
@@ -353,84 +108,4 @@ const styles = StyleSheet.create((theme) => ({
353
108
  zIndex: 1000,
354
109
  paddingBottom: accessoryOffset,
355
110
  }),
356
- // Normal flow places the accessory directly below the bar.
357
- stickyAccessory: { position: "relative" },
358
- // Anchors the absolutely positioned surface over the bar and the accessory both.
359
- barGroup: { position: "relative" },
360
- bar: {
361
- height: theme.layout.header.height,
362
- flexDirection: "row",
363
- alignItems: "center",
364
- paddingHorizontal: theme.layout.screen.padding.x,
365
- gap: theme.spacing["3"],
366
- justifyContent: "space-between",
367
- },
368
- // A centered title is absolutely positioned, so it leaves the flex row. With
369
- // no leading slot the trailing toolbar would be the only in-flow child and
370
- // `space-between` would park it on the left — the auto margin pins it right.
371
- trailing: {
372
- justifyContent: "flex-end",
373
- variants: {
374
- titleAlignment: {
375
- center: { marginLeft: "auto" },
376
- left: {},
377
- },
378
- },
379
- },
380
- portalContent: (hasAccessoryBelow: boolean) => ({
381
- gap: 2,
382
- // Include the gap in the measured height used to position the accessory.
383
- paddingBottom: hasAccessoryBelow ? theme.spacing["2"] : 0,
384
- paddingHorizontal: theme.layout.screen.padding.x,
385
- }),
386
- overline: {
387
- fontSize: theme.typography.variants["label-sm"].fontSize,
388
- lineHeight: theme.typography.variants["label-sm"].lineHeight,
389
- letterSpacing: theme.typography.variants["label-sm"].letterSpacing,
390
- fontWeight: FONT_WEIGHT_VALUE[theme.typography.variants["label-sm"].fontWeight],
391
- color: (theme.color as unknown as Record<string, string | undefined>)["text.tertiary"] ?? "",
392
- },
393
- largeTitle: {
394
- fontSize: theme.typography.variants["headline-lg"].fontSize,
395
- lineHeight: theme.typography.variants["headline-lg"].lineHeight,
396
- letterSpacing: theme.typography.variants["headline-lg"].letterSpacing,
397
- fontWeight: FONT_WEIGHT_VALUE[theme.typography.variants["headline-lg"].fontWeight],
398
- color: (theme.color as unknown as Record<string, string | undefined>)["text.primary"] ?? "",
399
- },
400
- subtitle: {
401
- fontSize: theme.typography.variants["body-lg"].fontSize,
402
- lineHeight: theme.typography.variants["body-lg"].lineHeight,
403
- letterSpacing: theme.typography.variants["body-lg"].letterSpacing,
404
- color: (theme.color as unknown as Record<string, string | undefined>)["text.tertiary"] ?? "",
405
- },
406
- compactTitle: {
407
- fontSize: theme.typography.variants["title-md"].fontSize,
408
- lineHeight: theme.typography.variants["title-md"].lineHeight,
409
- letterSpacing: theme.typography.variants["title-md"].letterSpacing,
410
- fontWeight: FONT_WEIGHT_VALUE[theme.typography.variants["title-md"].fontWeight],
411
- color: (theme.color as unknown as Record<string, string | undefined>)["text.primary"] ?? "",
412
- variants: {
413
- titleAlignment: {
414
- center: { textAlign: "center" },
415
- left: { textAlign: "left" },
416
- },
417
- },
418
- },
419
- titleContainer: {
420
- flex: 1,
421
- variants: {
422
- titleAlignment: {
423
- center: {
424
- position: "absolute",
425
- left: 0,
426
- right: 0,
427
- alignItems: "center",
428
- },
429
- left: {
430
- flex: 1,
431
- alignItems: "flex-start",
432
- },
433
- },
434
- },
435
- },
436
111
  }));
@@ -0,0 +1,88 @@
1
+ import type { ComponentProps } from "react";
2
+ import { type LayoutChangeEvent, Text } from "react-native";
3
+
4
+ import Animated from "react-native-reanimated";
5
+ import { StyleSheet, useUnistyles } from "react-native-unistyles";
6
+
7
+ import { FONT_WEIGHT_VALUE } from "#utils/typography";
8
+ import { isTextTitle } from "../../../utils";
9
+ import type { LargeTitleHeaderProps } from "..";
10
+
11
+ export function LargeTitleContent({
12
+ title,
13
+ overline,
14
+ subtitle,
15
+ hasAccessoryBelow,
16
+ onHeightChange,
17
+ animatedStyle,
18
+ }: Pick<LargeTitleHeaderProps, "title" | "overline" | "subtitle"> & {
19
+ /** Reserve a gap before a sticky accessory below this block. */
20
+ hasAccessoryBelow: boolean;
21
+ /** Reports the large-title block's layout height in dp. */
22
+ onHeightChange?: (height: number) => void;
23
+ animatedStyle: ComponentProps<typeof Animated.View>["style"];
24
+ }) {
25
+ const { theme } = useUnistyles();
26
+ // Layout height excludes the collapse transform.
27
+ const handleLayout = (event: LayoutChangeEvent) => onHeightChange?.(event.nativeEvent.layout.height);
28
+
29
+ return (
30
+ <Animated.View style={[styles.portalContent(hasAccessoryBelow), animatedStyle]} onLayout={handleLayout}>
31
+ {!!overline && (
32
+ <Text
33
+ style={styles.overline}
34
+ maxFontSizeMultiplier={theme.typography.variants["label-sm"].maxFontSizeMultiplier}
35
+ >
36
+ {overline}
37
+ </Text>
38
+ )}
39
+ {isTextTitle(title) ? (
40
+ <Text
41
+ style={styles.largeTitle}
42
+ maxFontSizeMultiplier={theme.typography.variants["headline-lg"].maxFontSizeMultiplier}
43
+ >
44
+ {title}
45
+ </Text>
46
+ ) : (
47
+ title
48
+ )}
49
+ {!!subtitle && (
50
+ <Text
51
+ style={styles.subtitle}
52
+ maxFontSizeMultiplier={theme.typography.variants["body-lg"].maxFontSizeMultiplier}
53
+ >
54
+ {subtitle}
55
+ </Text>
56
+ )}
57
+ </Animated.View>
58
+ );
59
+ }
60
+
61
+ const styles = StyleSheet.create((theme) => ({
62
+ portalContent: (hasAccessoryBelow: boolean) => ({
63
+ gap: theme.spacing["1"] / 2,
64
+ // Include the gap in the measured height used to position the accessory.
65
+ paddingBottom: hasAccessoryBelow ? theme.spacing["2"] : 0,
66
+ paddingHorizontal: theme.layout.screen.padding.x,
67
+ }),
68
+ overline: {
69
+ fontSize: theme.typography.variants["label-sm"].fontSize,
70
+ lineHeight: theme.typography.variants["label-sm"].lineHeight,
71
+ letterSpacing: theme.typography.variants["label-sm"].letterSpacing,
72
+ fontWeight: FONT_WEIGHT_VALUE[theme.typography.variants["label-sm"].fontWeight],
73
+ color: (theme.color as unknown as Record<string, string | undefined>)["text.tertiary"] ?? "",
74
+ },
75
+ largeTitle: {
76
+ fontSize: theme.typography.variants["headline-lg"].fontSize,
77
+ lineHeight: theme.typography.variants["headline-lg"].lineHeight,
78
+ letterSpacing: theme.typography.variants["headline-lg"].letterSpacing,
79
+ fontWeight: FONT_WEIGHT_VALUE[theme.typography.variants["headline-lg"].fontWeight],
80
+ color: (theme.color as unknown as Record<string, string | undefined>)["text.primary"] ?? "",
81
+ },
82
+ subtitle: {
83
+ fontSize: theme.typography.variants["body-lg"].fontSize,
84
+ lineHeight: theme.typography.variants["body-lg"].lineHeight,
85
+ letterSpacing: theme.typography.variants["body-lg"].letterSpacing,
86
+ color: (theme.color as unknown as Record<string, string | undefined>)["text.tertiary"] ?? "",
87
+ },
88
+ }));