panelui-native 0.94.0 → 0.96.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.
@@ -0,0 +1,709 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * ScrollHeader — a screen header that hands over to a compact bar as the page
5
+ * scrolls.
6
+ *
7
+ * A screen with a title has two states and every app draws both: the title
8
+ * large, at rest, with room around it, and the title small, pinned, once you
9
+ * are reading. Hand-rolling the change between them is where screens stop
10
+ * matching each other — one snaps, one crossfades at a different point, one
11
+ * forgets the bar is over a safe area — so the two states and the transit
12
+ * between them are one component here.
13
+ *
14
+ * ```tsx
15
+ * <ScrollHeader className="flex-1">
16
+ * <ScrollHeader.Bar>
17
+ * <ScrollHeader.Title>Library</ScrollHeader.Title>
18
+ * <ScrollHeader.Actions>
19
+ * <Button variant="ghost" size="icon"><SearchIcon size={18} /></Button>
20
+ * </ScrollHeader.Actions>
21
+ * </ScrollHeader.Bar>
22
+ * <ScrollHeader.Large>
23
+ * <ScrollHeader.Title>Library</ScrollHeader.Title>
24
+ * <ScrollHeader.Description>128 components</ScrollHeader.Description>
25
+ * </ScrollHeader.Large>
26
+ * <ScrollView>{rows}</ScrollView>
27
+ * </ScrollHeader>
28
+ * ```
29
+ *
30
+ * ## The band collapses; nothing inside it is animated up
31
+ *
32
+ * The header is one absolutely positioned band over the scroller, and the only
33
+ * thing driven by the scroll is its height: `bar + max(0, large - offset)`.
34
+ * The bar is anchored to its top and the large block to its bottom, so the
35
+ * band shrinking is what carries the large block up and behind the bar, and
36
+ * `overflow-hidden` is what cuts it off there.
37
+ *
38
+ * Driving the height rather than a `translateY` is what makes the rest fall
39
+ * out for free. Over-scrolling makes `large - offset` larger than `large`, so
40
+ * the band grows and a cover filling it stretches by being laid out bigger —
41
+ * no scale transform, so a photograph stretches without going soft. And the
42
+ * band's height is the inset the content needs, so there is one number rather
43
+ * than two that have to agree.
44
+ *
45
+ * ## The two titles cross-fade; neither one morphs
46
+ *
47
+ * The large title and the bar title are separate elements that fade past each
48
+ * other. A single title scaled and translated between the two positions tracks
49
+ * beautifully until the text is long enough to truncate, at which point it is
50
+ * animating between two different strings.
51
+ *
52
+ * Both are therefore in the tree at once, which is a problem for a screen
53
+ * reader — one of them is invisible and would still be read. So the crossing
54
+ * point is also published to React as `collapsed`, and whichever title is not
55
+ * being shown is hidden from accessibility. That is one re-render per crossing
56
+ * and no more: everything that runs per frame stays in shared values.
57
+ *
58
+ * ## The bar's surface is a fill, or a frost
59
+ *
60
+ * `surface` says what the bar is drawn on once it has taken over: a token
61
+ * fill, nothing at all over a cover, or `blur` — a real material, so the rows
62
+ * passing under the bar stay legible as shape and colour while losing the
63
+ * detail that would compete with the title on top.
64
+ *
65
+ * The frost needs a native view, and there are two ways it cannot be drawn:
66
+ * `expo-blur` is optional and may not be installed, and Reduce Transparency is
67
+ * a preference that outranks the design. Both fall back to the plain
68
+ * background token rather than to nothing, because a bar you cannot read is a
69
+ * worse answer than a bar that is not frosted.
70
+ *
71
+ * ## What it needs
72
+ *
73
+ * A height to fill, and exactly one scrollable child. The child is cloned with
74
+ * the scroll handler and the content inset composed onto it, the same way
75
+ * `ScrollFade` wraps one, so a `ScrollView`, a `FlatList` or a `SectionList`
76
+ * all work unchanged.
77
+ */
78
+ import { Children, createContext, forwardRef, isValidElement, useCallback, useContext, useMemo, useState } from 'react';
79
+ import { Image, Platform, StyleSheet, View } from 'react-native';
80
+ import { LinearGradient } from 'expo-linear-gradient';
81
+ import Animated, { interpolate, runOnJS, scrollTo, useAnimatedReaction, useAnimatedRef, useAnimatedScrollHandler, useAnimatedStyle, useComposedEventHandler, useDerivedValue, useReducedMotion, useSharedValue, withTiming } from 'react-native-reanimated';
82
+ import { useSafeAreaInsets } from 'react-native-safe-area-context';
83
+ import { tv } from 'tailwind-variants';
84
+ import { useCSSVariable } from 'uniwind';
85
+ import { hasBlur, useReduceTransparency } from "../../primitives/scrim.js";
86
+ import { Text, textChildren } from "../../primitives/text.js";
87
+ import { useThemeMode } from "../../theme/use-theme.js";
88
+ import { cn } from "../../utils/cn.js";
89
+ import { BAR_TITLE_ARRIVE, HANDOVER_DURATION, LARGE_EXIT, SURFACE_ARRIVE, bandHeight, collapseProgress, contentInset, hasSpan, isCrossing, snapTarget } from "./scroll-header-math.js";
90
+
91
+ /**
92
+ * Height of the pinned bar, in points, before the device's top inset is added.
93
+ * The platform navigation bars are 44 and 56; 48 is the target-size floor the
94
+ * rest of the library holds compact controls to, and it sits between them.
95
+ */
96
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
97
+ const BAR_HEIGHT = 48;
98
+
99
+ /**
100
+ * The scrollables Reanimated already animates. Its animated components are
101
+ * ordinary function components carrying the *inner* component's name, so there
102
+ * is nothing on one to test — but these two are module-level constants, and
103
+ * identity is exact.
104
+ */
105
+ /** Which way the frost tints. `default` follows the app's theme. */
106
+
107
+ /**
108
+ * `expo-blur`'s BlurView, or null when it is not installed. Resolved once at
109
+ * module load — the require is cheap and caching it avoids a try/catch on
110
+ * every render.
111
+ */
112
+ const BlurView = (() => {
113
+ try {
114
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
115
+ const mod = require('expo-blur');
116
+ return mod?.BlurView ?? null;
117
+ } catch {
118
+ return null;
119
+ }
120
+ })();
121
+
122
+ /**
123
+ * Depth of the frost. Heavier than a scrim's, because a scrim covers a whole
124
+ * screen and this is a thin band read against content that is moving under it.
125
+ */
126
+ const DEFAULT_BLUR_INTENSITY = 40;
127
+ const ANIMATED_SCROLLABLES = new Set([Animated.ScrollView, Animated.FlatList]);
128
+ const scrollHeaderVariants = tv({
129
+ slots: {
130
+ root: 'flex-1',
131
+ band: 'absolute inset-x-0 top-0 overflow-hidden',
132
+ cover: 'absolute inset-0',
133
+ bar: 'absolute inset-x-0 top-0 flex-row items-center gap-3 px-4',
134
+ barSurface: 'absolute inset-0',
135
+ large: 'absolute inset-x-0 bottom-0 gap-1 px-4 pb-3',
136
+ actions: 'ml-auto flex-row items-center gap-1'
137
+ },
138
+ variants: {
139
+ /** What the bar is drawn on once it has taken over. */
140
+ surface: {
141
+ plain: {
142
+ barSurface: 'bg-background'
143
+ },
144
+ muted: {
145
+ barSurface: 'bg-card'
146
+ },
147
+ none: {
148
+ barSurface: 'bg-transparent'
149
+ },
150
+ // No fill of its own: an opaque colour behind the material is part of
151
+ // what the material samples, so a frost over one is a flat bar that has
152
+ // paid for a native view.
153
+ blur: {
154
+ barSurface: 'bg-transparent'
155
+ }
156
+ },
157
+ divider: {
158
+ true: {
159
+ barSurface: 'border-b border-border'
160
+ },
161
+ false: {}
162
+ }
163
+ },
164
+ defaultVariants: {
165
+ surface: 'plain',
166
+ divider: true
167
+ }
168
+ });
169
+
170
+ /** What the bar is drawn on once the large block has gone. */
171
+
172
+ /** Which half of the header a part is standing in. */
173
+
174
+ const ScrollHeaderContext = /*#__PURE__*/createContext(undefined);
175
+ function useScrollHeader(component) {
176
+ const context = useContext(ScrollHeaderContext);
177
+ if (!context) throw new Error(`${component} must be used within a <ScrollHeader>`);
178
+ return context;
179
+ }
180
+
181
+ /**
182
+ * Which half a part is in. `Title` reads it to know which size to be and which
183
+ * way to fade, so the same element can be written in both places.
184
+ */
185
+ const ScrollHeaderSlotContext = /*#__PURE__*/createContext('large');
186
+ /**
187
+ * The band, and the scroller it sits over. Everything that is not a part is
188
+ * treated as the scrollable child.
189
+ */
190
+ const ScrollHeaderRoot = /*#__PURE__*/forwardRef(({
191
+ className,
192
+ barHeight = BAR_HEIGHT,
193
+ threshold = 1,
194
+ snap = true,
195
+ stretch = true,
196
+ inset = true,
197
+ onCollapsedChange,
198
+ progress: externalProgress,
199
+ children,
200
+ ...props
201
+ }, ref) => {
202
+ const insets = useSafeAreaInsets();
203
+ const reducedMotion = useReducedMotion();
204
+ const insetTop = inset ? insets.top : 0;
205
+ const barBand = insetTop + barHeight;
206
+ const offset = useSharedValue(0);
207
+ const largeHeight = useSharedValue(0);
208
+ const [largeSize, setLargeSize] = useState(0);
209
+ const [collapsed, setCollapsed] = useState(false);
210
+ const scrollRef = useAnimatedRef();
211
+
212
+ // Measured into both a shared value, for the band's height, and React
213
+ // state, for the content inset — the inset is a resting number that only
214
+ // changes when the block is re-laid-out, so paying a render for it costs
215
+ // nothing per frame and keeps the scroller's props stable.
216
+ const measureLarge = useCallback(event => {
217
+ const {
218
+ height
219
+ } = event.nativeEvent.layout;
220
+ largeHeight.value = height;
221
+ setLargeSize(previous => Math.abs(previous - height) < 0.5 ? previous : height);
222
+ }, [largeHeight]);
223
+ const progress = useDerivedValue(() => {
224
+ const next = collapseProgress(offset.value, largeHeight.value, threshold);
225
+ // A header with a block hands over across that block's height, and the
226
+ // finger sets the pace. One without has no distance to interpolate over,
227
+ // so the step is timed rather than instant — the alternative is a bar
228
+ // whose surface appears between one frame and the next.
229
+ const value = hasSpan(largeHeight.value) ? next : withTiming(next, {
230
+ duration: HANDOVER_DURATION
231
+ });
232
+ if (externalProgress) externalProgress.value = value;
233
+ return value;
234
+ }, [threshold, externalProgress]);
235
+
236
+ // The one thing the transition tells React about. `collapsed` gates the
237
+ // accessibility of the two titles and is what `onCollapsedChange` reports;
238
+ // the reaction fires on the crossing, not on the frames either side of it.
239
+ const cross = useCallback(next => {
240
+ setCollapsed(next);
241
+ onCollapsedChange?.(next);
242
+ }, [onCollapsedChange]);
243
+ useAnimatedReaction(() => progress.value >= 1, (isCollapsed, was) => {
244
+ if (!isCrossing(isCollapsed, was)) return;
245
+ runOnJS(cross)(isCollapsed);
246
+ }, [cross]);
247
+ const settle = useCallback(velocity => {
248
+ 'worklet';
249
+
250
+ if (!snap) return;
251
+ const target = snapTarget(offset.value, largeHeight.value, velocity);
252
+ if (target === null) return;
253
+ scrollTo(scrollRef, 0, target, true);
254
+ }, [snap, largeHeight, offset, scrollRef]);
255
+ const {
256
+ parts,
257
+ scrollable
258
+ } = useMemo(() => splitChildren(children), [children]);
259
+ const childType = /*#__PURE__*/isValidElement(scrollable) ? scrollable.type : null;
260
+ // Keyed on the element type rather than the element: rebuilding the
261
+ // wrapper would remount the list and lose its scroll position. A child
262
+ // that is animated already is used as it stands — wrapping one twice is
263
+ // unsupported, and there is no marker on them to test for, so the two
264
+ // that exist are recognised by identity.
265
+ const AnimatedScrollable = useMemo(() => {
266
+ if (!childType) return null;
267
+ if (ANIMATED_SCROLLABLES.has(childType)) return childType;
268
+ return Animated.createAnimatedComponent(childType);
269
+ }, [childType]);
270
+ const childProps = /*#__PURE__*/isValidElement(scrollable) ? scrollable.props : {};
271
+ const childOnScroll = childProps.onScroll;
272
+
273
+ /*
274
+ * A consumer's own `onScroll` is kept, whichever of the two kinds it is,
275
+ * because silently dropping one looks like a bug in the scrolling rather
276
+ * than in the call site.
277
+ *
278
+ * `useEvent` returns an object wearing a function's type, so the two are
279
+ * told apart by what they actually are: an object is a Reanimated handler
280
+ * and composes onto ours, staying on the UI thread. A plain function
281
+ * cannot — `useComposedEventHandler` keeps only worklet handlers and drops
282
+ * anything else without a word — so it is called across the bridge
283
+ * instead, once per scroll event the child delivers. How often that is, is
284
+ * `scrollEventThrottle`'s answer rather than this component's: every frame
285
+ * at the default of 16, and as rare as the caller asks for above it.
286
+ */
287
+ const workletOnScroll = typeof childOnScroll === 'function' ? null : childOnScroll ?? null;
288
+ const plainOnScroll = typeof childOnScroll === 'function' ? childOnScroll : null;
289
+
290
+ // Rebuilt only when the callback itself changes, so the worklet below is
291
+ // not rebuilt on every render of the child.
292
+ const forwardScroll = useCallback(native => {
293
+ // The `nativeEvent` crosses whole; the synthetic wrapper around it does
294
+ // not, because the rest of one is a live object with methods on it.
295
+ plainOnScroll?.({
296
+ nativeEvent: native
297
+ });
298
+ }, [plainOnScroll]);
299
+ const forwards = plainOnScroll !== null;
300
+ const scrollHandler = useAnimatedScrollHandler({
301
+ onScroll: event => {
302
+ offset.value = event.contentOffset.y;
303
+ if (forwards) {
304
+ // Every field React Native puts on a scroll event, so a callback
305
+ // reading `velocity` for a direction, or `targetContentOffset` for
306
+ // where a fling is going, finds what it would have without the
307
+ // header. Listed rather than spread: the event carries Reanimated's
308
+ // own `eventName` too, and that is not part of the contract.
309
+ runOnJS(forwardScroll)({
310
+ contentInset: event.contentInset,
311
+ contentOffset: event.contentOffset,
312
+ contentSize: event.contentSize,
313
+ layoutMeasurement: event.layoutMeasurement,
314
+ velocity: event.velocity,
315
+ zoomScale: event.zoomScale,
316
+ targetContentOffset: event.targetContentOffset
317
+ });
318
+ }
319
+ },
320
+ onEndDrag: event => {
321
+ settle(event.velocity?.y ?? 0);
322
+ },
323
+ onMomentumEnd: () => {
324
+ settle(0);
325
+ }
326
+ }, [settle, forwards, forwardScroll]);
327
+ const onScroll = useComposedEventHandler([scrollHandler, workletOnScroll ?? null]);
328
+
329
+ /*
330
+ * The band's resting height, plus whatever top padding the child asked
331
+ * for. A style array overrides rather than adds, so composing ours after
332
+ * theirs would drop their padding and composing theirs after ours would
333
+ * drop the inset — and they cannot write the sum themselves, because the
334
+ * band's height is measured. Reading it and adding is the only
335
+ * arrangement where both survive.
336
+ */
337
+ const childContentStyle = StyleSheet.flatten(childProps.contentContainerStyle) ?? {};
338
+ const headerHeight = contentInset(barBand + largeSize, childContentStyle.paddingTop ?? childContentStyle.paddingVertical ?? childContentStyle.padding);
339
+ const bandStyle = useAnimatedStyle(() => ({
340
+ height: bandHeight(barBand, largeHeight.value, offset.value, stretch && !reducedMotion)
341
+ }), [barBand, stretch, reducedMotion]);
342
+ const metrics = useMemo(() => ({
343
+ barBand,
344
+ insetTop
345
+ }), [barBand, insetTop]);
346
+ const hasLarge = largeSize >= 1;
347
+ const context = useMemo(() => ({
348
+ progress,
349
+ offset,
350
+ largeHeight,
351
+ collapsed,
352
+ hasLarge,
353
+ measureLarge
354
+ }), [progress, offset, largeHeight, collapsed, hasLarge, measureLarge]);
355
+ const {
356
+ root,
357
+ band
358
+ } = scrollHeaderVariants();
359
+ return /*#__PURE__*/_jsx(ScrollHeaderContext.Provider, {
360
+ value: context,
361
+ children: /*#__PURE__*/_jsxs(View, {
362
+ ...props,
363
+ ref: ref,
364
+ className: root({
365
+ className
366
+ }),
367
+ children: [AnimatedScrollable && /*#__PURE__*/isValidElement(scrollable) ? /*#__PURE__*/_jsx(AnimatedScrollable, {
368
+ ...childProps,
369
+ ref: composeRefs(scrollRef, childProps.ref),
370
+ onScroll: onScroll,
371
+ scrollEventThrottle: childProps.scrollEventThrottle ?? 16
372
+ // The band is the inset. iOS would otherwise add one of its own
373
+ // on top of it, for a header it cannot see.
374
+ ,
375
+ contentInsetAdjustmentBehavior: childProps.contentInsetAdjustmentBehavior ?? 'never',
376
+ contentContainerStyle: [childProps.contentContainerStyle, {
377
+ paddingTop: headerHeight
378
+ }],
379
+ scrollIndicatorInsets: {
380
+ top: barBand,
381
+ ...childProps.scrollIndicatorInsets
382
+ }
383
+ }) : scrollable, /*#__PURE__*/_jsx(Animated.View, {
384
+ pointerEvents: "box-none",
385
+ style: [bandStyle, Platform.OS === 'android' ? styles.lift : null],
386
+ className: band(),
387
+ children: /*#__PURE__*/_jsxs(BandMetricsContext.Provider, {
388
+ value: metrics,
389
+ children: [parts.cover, parts.large, parts.bar]
390
+ })
391
+ })]
392
+ })
393
+ });
394
+ });
395
+ ScrollHeaderRoot.displayName = 'ScrollHeader';
396
+ const BandMetricsContext = /*#__PURE__*/createContext({
397
+ barBand: BAR_HEIGHT,
398
+ insetTop: 0
399
+ });
400
+ /**
401
+ * The pinned bar. Its contents never move; its surface fades in as the large
402
+ * block leaves, which is what makes the change read as one crossfade rather
403
+ * than two things happening at once.
404
+ */
405
+ const ScrollHeaderBar = /*#__PURE__*/forwardRef(({
406
+ className,
407
+ surface = 'plain',
408
+ divider = true,
409
+ intensity = DEFAULT_BLUR_INTENSITY,
410
+ material = 'default',
411
+ children,
412
+ ...props
413
+ }, ref) => {
414
+ const {
415
+ progress
416
+ } = useScrollHeader('ScrollHeader.Bar');
417
+ const {
418
+ barBand,
419
+ insetTop
420
+ } = useContext(BandMetricsContext);
421
+ const {
422
+ mode
423
+ } = useThemeMode();
424
+ const reduceTransparency = useReduceTransparency();
425
+
426
+ // Three gates, and a preference that has not been answered yet counts as
427
+ // switched on: the case worth being careful about is the one where it is.
428
+ const blurring = surface === 'blur' && hasBlur && BlurView !== null && reduceTransparency === false;
429
+ const fill = surface === 'blur' && !blurring ? 'plain' : surface;
430
+ const {
431
+ bar,
432
+ barSurface
433
+ } = scrollHeaderVariants({
434
+ surface: fill,
435
+ divider
436
+ });
437
+
438
+ // Closed by the time the block has gone, so the block is never seen through
439
+ // it — and never on top of whatever else the bar is carrying.
440
+ const surfaceStyle = useAnimatedStyle(() => ({
441
+ opacity: interpolate(progress.value, SURFACE_ARRIVE, [0, 1], 'clamp')
442
+ }));
443
+ return /*#__PURE__*/_jsxs(View, {
444
+ ...props,
445
+ ref: ref
446
+ // The bar owns only its own band. `box-none` above it lets a pull
447
+ // land on the scroller rather than on the header covering it.
448
+ ,
449
+ style: {
450
+ height: barBand,
451
+ paddingTop: insetTop
452
+ },
453
+ className: bar({
454
+ className
455
+ }),
456
+ children: [/*#__PURE__*/_jsx(Animated.View, {
457
+ pointerEvents: "none",
458
+ style: surfaceStyle,
459
+ className: barSurface(),
460
+ children: blurring && BlurView ? /*#__PURE__*/_jsx(BlurView, {
461
+ intensity: intensity,
462
+ tint: material === 'default' ? mode : material,
463
+ style: StyleSheet.absoluteFill
464
+ }) : null
465
+ }), /*#__PURE__*/_jsx(ScrollHeaderSlotContext.Provider, {
466
+ value: "bar",
467
+ children: textChildren(children)
468
+ })]
469
+ });
470
+ });
471
+ ScrollHeaderBar.displayName = 'ScrollHeader.Bar';
472
+ /**
473
+ * The expanded block. Its measured height is the distance the header
474
+ * collapses over, so whatever is put in it — a title, a search field, a row of
475
+ * chips — sets the scroll distance rather than a prop having to agree with it.
476
+ */
477
+ const ScrollHeaderLarge = /*#__PURE__*/forwardRef(({
478
+ className,
479
+ onLayout,
480
+ children,
481
+ ...props
482
+ }, ref) => {
483
+ const {
484
+ progress,
485
+ measureLarge,
486
+ collapsed
487
+ } = useScrollHeader('ScrollHeader.Large');
488
+ const {
489
+ large
490
+ } = scrollHeaderVariants();
491
+ const style = useAnimatedStyle(() => ({
492
+ opacity: interpolate(progress.value, LARGE_EXIT, [1, 0], 'clamp')
493
+ }));
494
+
495
+ // The measurement is this block's whole job, so it is taken first and a
496
+ // consumer's own `onLayout` runs after it rather than instead of it.
497
+ const measure = useCallback(event => {
498
+ measureLarge(event);
499
+ onLayout?.(event);
500
+ }, [measureLarge, onLayout]);
501
+ return /*#__PURE__*/_jsx(Animated.View, {
502
+ ...props,
503
+ ref: ref,
504
+ onLayout: measure
505
+ // Behind the bar and faded out by the time it gets there, so it must
506
+ // not keep taking touches that belong to the content underneath.
507
+ ,
508
+ pointerEvents: collapsed ? 'none' : 'box-none',
509
+ accessibilityElementsHidden: collapsed,
510
+ importantForAccessibility: collapsed ? 'no-hide-descendants' : 'auto',
511
+ style: style,
512
+ className: large({
513
+ className
514
+ }),
515
+ children: /*#__PURE__*/_jsx(ScrollHeaderSlotContext.Provider, {
516
+ value: "large",
517
+ children: textChildren(children)
518
+ })
519
+ });
520
+ });
521
+ ScrollHeaderLarge.displayName = 'ScrollHeader.Large';
522
+
523
+ /**
524
+ * The screen's title. Written in both halves and styled from whichever it is
525
+ * in: large and at rest in the block, compact and fading in on the bar.
526
+ */
527
+ const ScrollHeaderTitle = /*#__PURE__*/forwardRef(({
528
+ className,
529
+ ...props
530
+ }, ref) => {
531
+ const {
532
+ progress,
533
+ largeHeight,
534
+ collapsed,
535
+ hasLarge
536
+ } = useScrollHeader('ScrollHeader.Title');
537
+ const slot = useContext(ScrollHeaderSlotContext);
538
+
539
+ // A bar with no block above it is not handing over from anything: it is the
540
+ // only title the screen has, and it is wanted from the first frame. Fading it
541
+ // in with the collapse would leave that screen untitled until somebody
542
+ // scrolled it.
543
+ const style = useAnimatedStyle(() => ({
544
+ opacity: hasSpan(largeHeight.value) ? interpolate(progress.value, BAR_TITLE_ARRIVE, [0, 1], 'clamp') : 1
545
+ }));
546
+ if (slot === 'large') {
547
+ return /*#__PURE__*/_jsx(Text, {
548
+ ...props,
549
+ ref: ref,
550
+ size: "3xl",
551
+ weight: "bold",
552
+ accessibilityRole: "header",
553
+ className: cn('text-foreground', className)
554
+ });
555
+ }
556
+ return /*#__PURE__*/_jsx(Animated.View, {
557
+ style: style
558
+ // The large title is the one being read until the bar has taken over.
559
+ // Both are in the tree the whole time, and only one of them should be —
560
+ // unless there is no large title, in which case this one always is.
561
+ ,
562
+ accessibilityElementsHidden: hasLarge ? !collapsed : false,
563
+ importantForAccessibility: !hasLarge || collapsed ? 'auto' : 'no-hide-descendants',
564
+ className: "flex-1",
565
+ children: /*#__PURE__*/_jsx(Text, {
566
+ ...props,
567
+ ref: ref,
568
+ size: "base",
569
+ weight: "semibold",
570
+ numberOfLines: props.numberOfLines ?? 1,
571
+ accessibilityRole: "header",
572
+ className: cn('text-foreground', className)
573
+ })
574
+ });
575
+ });
576
+ ScrollHeaderTitle.displayName = 'ScrollHeader.Title';
577
+
578
+ /** The quiet line under the title — a count, a byline, a date. */
579
+ const ScrollHeaderDescription = /*#__PURE__*/forwardRef(({
580
+ className,
581
+ ...props
582
+ }, ref) => /*#__PURE__*/_jsx(Text, {
583
+ ...props,
584
+ ref: ref,
585
+ size: "sm",
586
+ muted: true,
587
+ className: className
588
+ }));
589
+ ScrollHeaderDescription.displayName = 'ScrollHeader.Description';
590
+ /**
591
+ * The controls at the trailing end of the bar. They stay put and stay
592
+ * reachable — only the bar's surface and title are part of the transition.
593
+ */
594
+ const ScrollHeaderActions = /*#__PURE__*/forwardRef(({
595
+ className,
596
+ children,
597
+ ...props
598
+ }, ref) => {
599
+ const {
600
+ actions
601
+ } = scrollHeaderVariants();
602
+ return /*#__PURE__*/_jsx(View, {
603
+ ...props,
604
+ ref: ref,
605
+ className: actions({
606
+ className
607
+ }),
608
+ children: textChildren(children)
609
+ });
610
+ });
611
+ ScrollHeaderActions.displayName = 'ScrollHeader.Actions';
612
+ /**
613
+ * A picture or a gradient filling the band. It has no height of its own — the
614
+ * band's height is its height, which is why it stretches on an over-scroll by
615
+ * being laid out larger rather than by being scaled up and going soft.
616
+ */
617
+ const ScrollHeaderCover = /*#__PURE__*/forwardRef(({
618
+ className,
619
+ source,
620
+ colors,
621
+ scrim = true,
622
+ children,
623
+ ...props
624
+ }, ref) => {
625
+ const {
626
+ cover
627
+ } = scrollHeaderVariants();
628
+ const seriesOne = useCSSVariable('--color-chart-1');
629
+ const seriesTwo = useCSSVariable('--color-chart-2');
630
+ const ramp = colors ?? [typeof seriesOne === 'string' ? seriesOne : '#6366f1', typeof seriesTwo === 'string' ? seriesTwo : '#8b5cf6'];
631
+ return /*#__PURE__*/_jsxs(View, {
632
+ ...props,
633
+ ref: ref,
634
+ pointerEvents: "none",
635
+ className: cover({
636
+ className
637
+ }),
638
+ children: [/*#__PURE__*/_jsx(LinearGradient, {
639
+ colors: ramp,
640
+ start: {
641
+ x: 0,
642
+ y: 0
643
+ },
644
+ end: {
645
+ x: 1,
646
+ y: 1
647
+ },
648
+ style: StyleSheet.absoluteFill
649
+ }), source ? /*#__PURE__*/_jsx(Image, {
650
+ source: source,
651
+ resizeMode: "cover",
652
+ style: StyleSheet.absoluteFill
653
+ }) : null, scrim ? /*#__PURE__*/_jsx(View, {
654
+ className: "absolute inset-0 bg-black/25"
655
+ }) : null, children]
656
+ });
657
+ });
658
+ ScrollHeaderCover.displayName = 'ScrollHeader.Cover';
659
+
660
+ /**
661
+ * Sort the children into the three band slots and the one scrollable. Sorting
662
+ * by component rather than by order is what lets the example above read
663
+ * top-down — bar, block, list — while the band still draws the cover first.
664
+ */
665
+ function splitChildren(children) {
666
+ let cover = null;
667
+ let large = null;
668
+ let bar = null;
669
+ let scrollable = null;
670
+ Children.forEach(children, child => {
671
+ if (! /*#__PURE__*/isValidElement(child)) return;
672
+ if (child.type === ScrollHeaderCover) cover = child;else if (child.type === ScrollHeaderLarge) large = child;else if (child.type === ScrollHeaderBar) bar = child;else if (!scrollable) scrollable = child;
673
+ });
674
+ return {
675
+ parts: {
676
+ cover,
677
+ large,
678
+ bar
679
+ },
680
+ scrollable
681
+ };
682
+ }
683
+
684
+ /** Point several refs at one node, skipping the ones that were not given. */
685
+ function composeRefs(...refs) {
686
+ return node => {
687
+ for (const ref of refs) {
688
+ if (typeof ref === 'function') ref(node);else if (ref && typeof ref === 'object') {
689
+ ref.current = node;
690
+ }
691
+ }
692
+ };
693
+ }
694
+ const styles = StyleSheet.create({
695
+ // Android draws by elevation before z-order, so a band with none of its own
696
+ // ends up under the scroller's own background.
697
+ lift: {
698
+ elevation: 4
699
+ }
700
+ });
701
+ export const ScrollHeader = Object.assign(ScrollHeaderRoot, {
702
+ Bar: ScrollHeaderBar,
703
+ Large: ScrollHeaderLarge,
704
+ Title: ScrollHeaderTitle,
705
+ Description: ScrollHeaderDescription,
706
+ Actions: ScrollHeaderActions,
707
+ Cover: ScrollHeaderCover
708
+ });
709
+ //# sourceMappingURL=index.js.map