panelui-native 0.92.0 → 0.94.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 (29) hide show
  1. package/lib/module/components/bottom-sheet/index.js +19 -2
  2. package/lib/module/components/bottom-sheet/index.js.map +1 -1
  3. package/lib/module/components/bubble-chart/index.js +23 -9
  4. package/lib/module/components/bubble-chart/index.js.map +1 -1
  5. package/lib/module/components/frame/index.js +178 -34
  6. package/lib/module/components/frame/index.js.map +1 -1
  7. package/lib/module/components/page-header/index.js +577 -0
  8. package/lib/module/components/page-header/index.js.map +1 -0
  9. package/lib/module/components/questionnaire/index.js +242 -22
  10. package/lib/module/components/questionnaire/index.js.map +1 -1
  11. package/lib/module/index.js +1 -0
  12. package/lib/module/index.js.map +1 -1
  13. package/lib/typescript/src/components/bottom-sheet/index.d.ts.map +1 -1
  14. package/lib/typescript/src/components/bubble-chart/index.d.ts.map +1 -1
  15. package/lib/typescript/src/components/frame/index.d.ts +19 -2
  16. package/lib/typescript/src/components/frame/index.d.ts.map +1 -1
  17. package/lib/typescript/src/components/page-header/index.d.ts +278 -0
  18. package/lib/typescript/src/components/page-header/index.d.ts.map +1 -0
  19. package/lib/typescript/src/components/questionnaire/index.d.ts +19 -5
  20. package/lib/typescript/src/components/questionnaire/index.d.ts.map +1 -1
  21. package/lib/typescript/src/index.d.ts +2 -1
  22. package/lib/typescript/src/index.d.ts.map +1 -1
  23. package/package.json +1 -1
  24. package/src/components/bottom-sheet/index.tsx +29 -3
  25. package/src/components/bubble-chart/index.tsx +29 -9
  26. package/src/components/frame/index.tsx +180 -29
  27. package/src/components/page-header/index.tsx +674 -0
  28. package/src/components/questionnaire/index.tsx +249 -19
  29. package/src/index.ts +17 -0
@@ -0,0 +1,674 @@
1
+ /**
2
+ * PageHeader — the top of a profile screen: a cover, the face over it, who it
3
+ * belongs to, and what you can do about them.
4
+ *
5
+ * Every social app opens a profile the same way, and rebuilding it per screen
6
+ * is how the cover ends up a different height on each one. The parts here are
7
+ * the pieces that arrangement is made of, and the two variants are the two
8
+ * places it lives: `card`, a self-contained surface in a scroll of other
9
+ * cards, and `page`, the header of the screen itself.
10
+ *
11
+ * ```tsx
12
+ * <PageHeader>
13
+ * <PageHeader.Cover />
14
+ * <PageHeader.Avatar source={face} fallback="OR" verified />
15
+ * <PageHeader.Content>
16
+ * <PageHeader.Title>Olivia Rhye</PageHeader.Title>
17
+ * <PageHeader.Description>@oliviarhye</PageHeader.Description>
18
+ * </PageHeader.Content>
19
+ * <PageHeader.Actions>
20
+ * <Button className="flex-1">Follow</Button>
21
+ * </PageHeader.Actions>
22
+ * </PageHeader>
23
+ * ```
24
+ *
25
+ * The cover in `card` is held off the card's edges and rounded on its top
26
+ * corners only, so its bottom edge meets the content rather than floating
27
+ * above it. The radius pair is `rounded-3xl` on the card and `rounded-2xl` on
28
+ * the cover, one step apart on the theme's own scale — which is the same 8
29
+ * points the card is padded by, so the two curves stay concentric without a
30
+ * hardcoded number that would be wrong in half the themes.
31
+ *
32
+ * The avatar lifts itself over the cover's bottom edge, and it works out
33
+ * whether to: the root looks for a `PageHeader.Cover` among its children and
34
+ * says so through context, because a header with no cover has nothing to
35
+ * overlap and an avatar that lifted anyway would hang off the top of it.
36
+ *
37
+ * The ring around the face is drawn in the surface behind it — the card, or
38
+ * the page — rather than in a border colour, so the face reads as punched out
39
+ * of the cover instead of outlined on top of it.
40
+ */
41
+ import {
42
+ Children,
43
+ cloneElement,
44
+ createContext,
45
+ forwardRef,
46
+ isValidElement,
47
+ useContext,
48
+ useMemo,
49
+ type ReactNode,
50
+ } from 'react';
51
+ import {
52
+ Image,
53
+ StyleSheet,
54
+ View,
55
+ type ImageSourcePropType,
56
+ type PressableProps,
57
+ type Text as RNText,
58
+ type ViewProps,
59
+ } from 'react-native';
60
+ import { LinearGradient } from 'expo-linear-gradient';
61
+ import { tv, type VariantProps } from 'tailwind-variants';
62
+ import { useCSSVariable } from 'uniwind';
63
+ import { AnimatedPressable } from '../../primitives/animated-pressable';
64
+ import { Text, type TextProps, textChildren } from '../../primitives/text';
65
+ import { cn } from '../../utils/cn';
66
+ import { BadgeCheckIcon, IconColorProvider } from '../../icons';
67
+ import { Avatar, AVATAR_SIZE_POINTS, type AvatarProps, type AvatarSizeName } from '../avatar';
68
+
69
+ /** Height of a cover that is not given one. Two lines of nothing, and enough
70
+ * of a band for a face to sit half over. */
71
+ const COVER_HEIGHT = 120;
72
+
73
+ /** Points of surface drawn around the face. */
74
+ const AVATAR_RING = 4;
75
+
76
+ /** The rosette, as a share of the face it sits on. */
77
+ const BADGE_RATIO = 0.3;
78
+
79
+ /** The cover gradient, when no image is given. Both are series tokens, so an
80
+ * app that puts its charts on brand puts this on brand with them. */
81
+ const GRADIENT = ['--color-chart-2', '--color-chart-5'] as const;
82
+ const GRADIENT_FALLBACK = ['#3b82f6', '#8b5cf6'] as const;
83
+
84
+ const pageHeaderVariants = tv({
85
+ slots: {
86
+ root: 'w-full',
87
+ cover: 'overflow-hidden bg-muted',
88
+ avatar: 'self-start',
89
+ ring: 'overflow-hidden rounded-full',
90
+ row: 'flex-row items-center gap-5',
91
+ content: 'gap-1',
92
+ meta: 'flex-row items-center gap-1.5',
93
+ stats: 'flex-row items-center',
94
+ actions: 'flex-row items-center gap-2',
95
+ },
96
+ variants: {
97
+ variant: {
98
+ // `overflow-hidden` clips the cover's own square bottom corners to the
99
+ // card's radius on the way down; `p-2` is what holds the cover off the
100
+ // card's edges, and is the difference between the two radii above.
101
+ card: {
102
+ root: 'overflow-hidden rounded-3xl border border-border bg-card p-2',
103
+ cover: 'rounded-t-2xl',
104
+ ring: 'border-card',
105
+ avatar: 'px-3',
106
+ row: 'px-3',
107
+ content: 'px-3 pt-3',
108
+ actions: 'px-3 pb-2 pt-4',
109
+ },
110
+ // No surface of its own, and the cover runs to the screen's edges.
111
+ page: {
112
+ root: 'bg-background',
113
+ ring: 'border-background',
114
+ avatar: 'px-4',
115
+ row: 'px-4',
116
+ content: 'px-4 pt-3',
117
+ actions: 'px-4 pb-4 pt-4',
118
+ },
119
+ },
120
+ align: {
121
+ start: {},
122
+ center: {
123
+ avatar: 'self-center',
124
+ row: 'justify-center',
125
+ content: 'items-center',
126
+ meta: 'justify-center',
127
+ stats: 'justify-center',
128
+ actions: 'justify-center',
129
+ },
130
+ },
131
+ },
132
+ defaultVariants: {
133
+ variant: 'card',
134
+ align: 'center',
135
+ },
136
+ });
137
+
138
+ type PageHeaderVariantProps = VariantProps<typeof pageHeaderVariants>;
139
+
140
+ /** Where the header lives: a card in a scroll, or the top of the screen. */
141
+ export type PageHeaderVariant = NonNullable<PageHeaderVariantProps['variant']>;
142
+
143
+ /** Which edge the face and the text line up on. */
144
+ export type PageHeaderAlign = NonNullable<PageHeaderVariantProps['align']>;
145
+
146
+ interface PageHeaderContextValue {
147
+ variant: PageHeaderVariant;
148
+ align: PageHeaderAlign;
149
+ hasCover: boolean;
150
+ }
151
+
152
+ const PageHeaderContext = createContext<PageHeaderContextValue | undefined>(undefined);
153
+
154
+ function usePageHeader(component: string): PageHeaderContextValue {
155
+ const context = useContext(PageHeaderContext);
156
+ if (!context) throw new Error(`${component} must be used within a <PageHeader>`);
157
+ return context;
158
+ }
159
+
160
+ /**
161
+ * True inside a `PageHeader.Row`. The avatar carries the body's inset and its
162
+ * own alignment when it stands alone, and neither when something else is
163
+ * already holding the row it sits in.
164
+ */
165
+ const PageHeaderRowContext = createContext(false);
166
+
167
+ /** How a stats row reads its numbers out. */
168
+ const PageHeaderStatsContext = createContext<PageHeaderStatsLayout>('stacked');
169
+
170
+ export interface PageHeaderProps extends ViewProps {
171
+ className?: string;
172
+ children?: ReactNode;
173
+ }
174
+
175
+ export interface PageHeaderRootProps extends PageHeaderProps {
176
+ /**
177
+ * `card` is a surface of its own, with the cover held off its edges. `page`
178
+ * drops the surface and lets the cover run to the screen's edges.
179
+ */
180
+ variant?: PageHeaderVariant;
181
+ /**
182
+ * Which edge the face, the text and the actions line up on. `center` is the
183
+ * profile card; `start` is the screen header, where the name is the first
184
+ * thing on the line rather than the middle of it.
185
+ */
186
+ align?: PageHeaderAlign;
187
+ }
188
+
189
+ const PageHeaderRoot = forwardRef<View, PageHeaderRootProps>(
190
+ ({ className, variant = 'card', align = 'center', children, ...props }, ref) => {
191
+ // Whether the avatar has anything to overlap. Read off the children rather
192
+ // than asked for, because a caller who has written a cover should not also
193
+ // have to say that they have.
194
+ const hasCover = Children.toArray(children).some(
195
+ (child) => isValidElement(child) && child.type === PageHeaderCover
196
+ );
197
+
198
+ const context = useMemo(
199
+ () => ({ variant, align, hasCover }),
200
+ [variant, align, hasCover]
201
+ );
202
+
203
+ return (
204
+ <View
205
+ {...props}
206
+ ref={ref}
207
+ className={pageHeaderVariants({ variant, align }).root({ className })}
208
+ >
209
+ <PageHeaderContext.Provider value={context}>{children}</PageHeaderContext.Provider>
210
+ </View>
211
+ );
212
+ }
213
+ );
214
+ PageHeaderRoot.displayName = 'PageHeader';
215
+
216
+ export interface PageHeaderCoverProps extends ViewProps {
217
+ className?: string;
218
+ /** The banner. Left out, the cover draws a gradient instead. */
219
+ source?: ImageSourcePropType;
220
+ /** How tall the band is. */
221
+ height?: number;
222
+ /**
223
+ * The gradient, when there is no image. Two colours or more, as real colour
224
+ * strings — this is painted rather than classed.
225
+ */
226
+ colors?: readonly [string, string, ...string[]];
227
+ /** What the banner shows. Left out, it is treated as decoration. */
228
+ alt?: string;
229
+ children?: ReactNode;
230
+ }
231
+
232
+ /**
233
+ * The banner. An image when given one, a gradient when not — a header with no
234
+ * banner still has to read as a header, and an empty band reads as a bug.
235
+ */
236
+ const PageHeaderCover = forwardRef<View, PageHeaderCoverProps>(
237
+ ({ className, source, height = COVER_HEIGHT, colors, alt, style, children, ...props }, ref) => {
238
+ const { variant } = usePageHeader('PageHeader.Cover');
239
+ const from = useCSSVariable(GRADIENT[0]);
240
+ const to = useCSSVariable(GRADIENT[1]);
241
+
242
+ // `useCSSVariable` can hand back something that is not a colour string, and
243
+ // a gradient given one paints nothing at all.
244
+ const ramp =
245
+ colors ??
246
+ ([
247
+ typeof from === 'string' ? from : GRADIENT_FALLBACK[0],
248
+ typeof to === 'string' ? to : GRADIENT_FALLBACK[1],
249
+ ] as const);
250
+
251
+ return (
252
+ <View
253
+ {...props}
254
+ ref={ref}
255
+ className={pageHeaderVariants({ variant }).cover({ className })}
256
+ style={[{ height }, style]}
257
+ >
258
+ {source ? (
259
+ <Image
260
+ source={source}
261
+ resizeMode="cover"
262
+ style={StyleSheet.absoluteFill}
263
+ accessible={!!alt}
264
+ accessibilityRole={alt ? 'image' : undefined}
265
+ accessibilityLabel={alt}
266
+ />
267
+ ) : (
268
+ <LinearGradient
269
+ colors={ramp}
270
+ start={{ x: 0, y: 0 }}
271
+ end={{ x: 0, y: 1 }}
272
+ // Filled by style rather than by class. `className` on a component
273
+ // from another package is only as good as that package's style
274
+ // forwarding, and a gradient that silently measures zero is a
275
+ // banner that is simply not there.
276
+ style={StyleSheet.absoluteFill}
277
+ accessibilityElementsHidden
278
+ importantForAccessibility="no-hide-descendants"
279
+ />
280
+ )}
281
+ {children}
282
+ </View>
283
+ );
284
+ }
285
+ );
286
+ PageHeaderCover.displayName = 'PageHeader.Cover';
287
+
288
+ export interface PageHeaderAvatarProps extends Omit<AvatarProps, 'size' | 'children'> {
289
+ className?: string;
290
+ /** How big the face is. */
291
+ size?: AvatarSizeName;
292
+ /**
293
+ * What goes in the ring instead of a face — a logo, a monogram, a live
294
+ * thumbnail. It fills the ring, so give it its own background and padding.
295
+ */
296
+ children?: ReactNode;
297
+ /** Draws the verification rosette in the face's bottom corner. */
298
+ verified?: boolean;
299
+ /**
300
+ * Anything else for that corner — a camera button, a presence dot, a "+".
301
+ * Wins over `verified`.
302
+ */
303
+ badge?: ReactNode;
304
+ /**
305
+ * Whether the face lifts over the cover's bottom edge. Set by the presence
306
+ * of a `PageHeader.Cover`; pass it to override that either way.
307
+ */
308
+ overlap?: boolean;
309
+ }
310
+
311
+ /**
312
+ * The face, in a ring of whatever surface is behind it.
313
+ *
314
+ * The badge is drawn here rather than with `Avatar.Badge` because that one is
315
+ * pinned to the top corner, where an unread count belongs. A verification mark
316
+ * belongs at the bottom, beside the name it is vouching for.
317
+ */
318
+ const PageHeaderAvatar = forwardRef<View, PageHeaderAvatarProps>(
319
+ (
320
+ {
321
+ className,
322
+ size = 'xl',
323
+ verified,
324
+ badge,
325
+ overlap,
326
+ source,
327
+ fallback,
328
+ imageProps,
329
+ children,
330
+ style,
331
+ ...props
332
+ },
333
+ ref
334
+ ) => {
335
+ const { variant, align, hasCover } = usePageHeader('PageHeader.Avatar');
336
+ const inRow = useContext(PageHeaderRowContext);
337
+ const slots = pageHeaderVariants({ variant, align });
338
+ const primary = useCSSVariable('--color-primary');
339
+ const onPrimary = useCSSVariable('--color-primary-foreground');
340
+
341
+ const diameter = AVATAR_SIZE_POINTS[size] ?? AVATAR_SIZE_POINTS.xl;
342
+ const lifts = overlap ?? hasCover;
343
+ const mark = badge ?? (verified ? (
344
+ <BadgeCheckIcon
345
+ size={Math.round(diameter * BADGE_RATIO)}
346
+ color={typeof primary === 'string' ? primary : '#2563eb'}
347
+ checkColor={typeof onPrimary === 'string' ? onPrimary : '#ffffff'}
348
+ />
349
+ ) : null);
350
+
351
+ return (
352
+ <View
353
+ {...props}
354
+ ref={ref}
355
+ // Inside a row the row is already holding the inset and the alignment;
356
+ // repeating them here would indent the face past the text under it.
357
+ className={inRow ? cn('self-start', className) : slots.avatar({ className })}
358
+ style={[lifts ? { marginTop: -Math.round(diameter / 2) } : null, style]}
359
+ >
360
+ {/*
361
+ Two views, because the ring clips. A badge hung on the clipping one
362
+ would be cut in half by the circle it is meant to sit against.
363
+ */}
364
+ <View
365
+ className={slots.ring()}
366
+ style={
367
+ // With a face inside, the ring is the face plus its own border.
368
+ // With anything else there is nothing to measure, so the ring is
369
+ // given the size the face would have made.
370
+ children
371
+ ? {
372
+ borderWidth: AVATAR_RING,
373
+ width: diameter + AVATAR_RING * 2,
374
+ height: diameter + AVATAR_RING * 2,
375
+ }
376
+ : { borderWidth: AVATAR_RING }
377
+ }
378
+ >
379
+ {children ?? (
380
+ <Avatar
381
+ size={size}
382
+ source={source}
383
+ fallback={fallback}
384
+ imageProps={imageProps}
385
+ className="border-0"
386
+ />
387
+ )}
388
+ </View>
389
+ {mark ? <View className="absolute bottom-0.5 end-0.5">{mark}</View> : null}
390
+ </View>
391
+ );
392
+ }
393
+ );
394
+ PageHeaderAvatar.displayName = 'PageHeader.Avatar';
395
+
396
+ export interface PageHeaderRowProps extends PageHeaderProps {
397
+ children?: ReactNode;
398
+ }
399
+
400
+ /**
401
+ * The face, and whatever sits beside it. For the profile that puts its counts
402
+ * next to the picture rather than under the name.
403
+ */
404
+ const PageHeaderRow = forwardRef<View, PageHeaderRowProps>(
405
+ ({ className, children, ...props }, ref) => {
406
+ const { variant, align } = usePageHeader('PageHeader.Row');
407
+ return (
408
+ <View
409
+ {...props}
410
+ ref={ref}
411
+ className={pageHeaderVariants({ variant, align }).row({ className })}
412
+ >
413
+ <PageHeaderRowContext.Provider value>{children}</PageHeaderRowContext.Provider>
414
+ </View>
415
+ );
416
+ }
417
+ );
418
+ PageHeaderRow.displayName = 'PageHeader.Row';
419
+
420
+ export interface PageHeaderContentProps extends PageHeaderProps {
421
+ children?: ReactNode;
422
+ }
423
+
424
+ /** The text block: the name, the handle, and anything under them. */
425
+ const PageHeaderContent = forwardRef<View, PageHeaderContentProps>(
426
+ ({ className, children, ...props }, ref) => {
427
+ const { variant, align } = usePageHeader('PageHeader.Content');
428
+ return (
429
+ <View
430
+ {...props}
431
+ ref={ref}
432
+ className={pageHeaderVariants({ variant, align }).content({ className })}
433
+ >
434
+ {textChildren(children)}
435
+ </View>
436
+ );
437
+ }
438
+ );
439
+ PageHeaderContent.displayName = 'PageHeader.Content';
440
+
441
+ /** Whose page it is. Announces itself as a heading. */
442
+ const PageHeaderTitle = forwardRef<RNText, TextProps>(({ className, ...props }, ref) => (
443
+ <Text
444
+ {...props}
445
+ ref={ref}
446
+ size="xl"
447
+ weight="semibold"
448
+ accessibilityRole="header"
449
+ className={cn('text-foreground', className)}
450
+ />
451
+ ));
452
+ PageHeaderTitle.displayName = 'PageHeader.Title';
453
+
454
+ /** The handle, the email, the bio — the quiet line under the name. */
455
+ const PageHeaderDescription = forwardRef<RNText, TextProps>(({ className, ...props }, ref) => (
456
+ <Text {...props} ref={ref} size="sm" muted className={className} />
457
+ ));
458
+ PageHeaderDescription.displayName = 'PageHeader.Description';
459
+
460
+ export interface PageHeaderMetaProps extends PageHeaderProps {
461
+ /** A glyph before the line. Takes the muted colour without being told. */
462
+ icon?: ReactNode;
463
+ children?: ReactNode;
464
+ }
465
+
466
+ /**
467
+ * One fact about the account — a link, a location, the month it was opened.
468
+ */
469
+ const PageHeaderMeta = forwardRef<View, PageHeaderMetaProps>(
470
+ ({ className, icon, children, ...props }, ref) => {
471
+ const { variant, align } = usePageHeader('PageHeader.Meta');
472
+ const muted = useCSSVariable('--color-muted-foreground');
473
+ return (
474
+ <View
475
+ {...props}
476
+ ref={ref}
477
+ className={pageHeaderVariants({ variant, align }).meta({ className })}
478
+ >
479
+ {icon ? (
480
+ <IconColorProvider color={typeof muted === 'string' ? muted : undefined}>
481
+ {icon}
482
+ </IconColorProvider>
483
+ ) : null}
484
+ {textChildren(children, (text) => (
485
+ <Text size="sm" muted>
486
+ {text}
487
+ </Text>
488
+ ))}
489
+ </View>
490
+ );
491
+ }
492
+ );
493
+ PageHeaderMeta.displayName = 'PageHeader.Meta';
494
+
495
+ /** How a stats row reads its numbers out. */
496
+ export type PageHeaderStatsLayout = 'stacked' | 'inline';
497
+
498
+ export interface PageHeaderStatsProps extends PageHeaderProps {
499
+ /**
500
+ * `stacked` puts the label under the figure, for a row of counts read as a
501
+ * set. `inline` runs them together — "533 Followers" — for counts read as
502
+ * part of a sentence.
503
+ */
504
+ layout?: PageHeaderStatsLayout;
505
+ /** Rule between each count and the next. */
506
+ divided?: boolean;
507
+ children?: ReactNode;
508
+ }
509
+
510
+ /**
511
+ * The row of counts.
512
+ *
513
+ * Centred and stacked, the counts take equal widths across the full row. Left
514
+ * to their content they are centred as a block but not as figures: "Followers"
515
+ * is twice the width of "Posts", so the middle count sits off the centre line
516
+ * the name and the buttons are on, and the row reads as very slightly wrong
517
+ * without it being obvious why.
518
+ */
519
+ const PageHeaderStats = forwardRef<View, PageHeaderStatsProps>(
520
+ ({ className, layout = 'stacked', divided, children, ...props }, ref) => {
521
+ const { variant, align } = usePageHeader('PageHeader.Stats');
522
+ const stacked = layout === 'stacked';
523
+ const measured = stacked && align === 'center';
524
+
525
+ // React Native has no `:first-child`, so the rule between two counts
526
+ // cannot be a style rule. Place it on every count but the first.
527
+ const items =
528
+ divided || measured
529
+ ? Children.map(children, (child, index) => {
530
+ if (!isValidElement<PageHeaderStatProps>(child)) return child;
531
+ if (child.type !== PageHeaderStat) return child;
532
+ const next: Partial<PageHeaderStatProps> = {};
533
+ if (divided && child.props.divided === undefined) {
534
+ next.divided = index > 0;
535
+ }
536
+ if (measured) {
537
+ next.className = cn('flex-1', child.props.className);
538
+ }
539
+ return cloneElement(child, next);
540
+ })
541
+ : children;
542
+
543
+ return (
544
+ <View
545
+ {...props}
546
+ ref={ref}
547
+ className={pageHeaderVariants({ variant, align }).stats({
548
+ className: cn(
549
+ stacked ? 'gap-6' : 'gap-4',
550
+ measured && 'w-full',
551
+ className
552
+ ),
553
+ })}
554
+ >
555
+ <PageHeaderStatsContext.Provider value={layout}>
556
+ {items}
557
+ </PageHeaderStatsContext.Provider>
558
+ </View>
559
+ );
560
+ }
561
+ );
562
+ PageHeaderStats.displayName = 'PageHeader.Stats';
563
+
564
+ export interface PageHeaderStatProps extends Omit<PressableProps, 'children'> {
565
+ className?: string;
566
+ /** The figure. */
567
+ value: ReactNode;
568
+ /** What it counts. */
569
+ label: ReactNode;
570
+ /** Rule before this count. `PageHeader.Stats` sets it; pass it to override. */
571
+ divided?: boolean;
572
+ }
573
+
574
+ /**
575
+ * One count. Announced as a single thing — "533 Followers" — because a figure
576
+ * and its label read apart are two pieces of nothing.
577
+ */
578
+ const PageHeaderStat = forwardRef<View, PageHeaderStatProps>(
579
+ ({ className, value, label, divided, onPress, accessibilityLabel, ...props }, ref) => {
580
+ const layout = useContext(PageHeaderStatsContext);
581
+ const inline = layout === 'inline';
582
+
583
+ const spoken =
584
+ accessibilityLabel ??
585
+ (isSpeakable(value) && isSpeakable(label) ? `${value} ${label}` : undefined);
586
+
587
+ const classes = cn(
588
+ inline ? 'flex-row items-center gap-1' : 'items-center gap-0.5',
589
+ divided && 'border-s border-border ps-4',
590
+ className
591
+ );
592
+
593
+ const body = (
594
+ <>
595
+ <Text size="sm" weight="semibold">
596
+ {value}
597
+ </Text>
598
+ <Text size="sm" muted>
599
+ {label}
600
+ </Text>
601
+ </>
602
+ );
603
+
604
+ if (!onPress) {
605
+ return (
606
+ <View
607
+ {...(props as ViewProps)}
608
+ ref={ref}
609
+ accessible
610
+ accessibilityLabel={spoken}
611
+ className={classes}
612
+ >
613
+ {body}
614
+ </View>
615
+ );
616
+ }
617
+
618
+ return (
619
+ <AnimatedPressable
620
+ {...props}
621
+ ref={ref}
622
+ accessibilityRole="button"
623
+ accessibilityLabel={spoken}
624
+ onPress={onPress}
625
+ className={classes}
626
+ >
627
+ {body}
628
+ </AnimatedPressable>
629
+ );
630
+ }
631
+ );
632
+ PageHeaderStat.displayName = 'PageHeader.Stat';
633
+
634
+ /** Whether a node can be read into an accessibility label as it stands. */
635
+ function isSpeakable(node: ReactNode): node is string | number {
636
+ return typeof node === 'string' || typeof node === 'number';
637
+ }
638
+
639
+ export interface PageHeaderActionsProps extends PageHeaderProps {
640
+ children?: ReactNode;
641
+ }
642
+
643
+ /**
644
+ * What you can do about the account. Give the buttons `className="flex-1"` for
645
+ * the pair that splits the width evenly.
646
+ */
647
+ const PageHeaderActions = forwardRef<View, PageHeaderActionsProps>(
648
+ ({ className, children, ...props }, ref) => {
649
+ const { variant, align } = usePageHeader('PageHeader.Actions');
650
+ return (
651
+ <View
652
+ {...props}
653
+ ref={ref}
654
+ className={pageHeaderVariants({ variant, align }).actions({ className })}
655
+ >
656
+ {textChildren(children)}
657
+ </View>
658
+ );
659
+ }
660
+ );
661
+ PageHeaderActions.displayName = 'PageHeader.Actions';
662
+
663
+ export const PageHeader = Object.assign(PageHeaderRoot, {
664
+ Cover: PageHeaderCover,
665
+ Avatar: PageHeaderAvatar,
666
+ Row: PageHeaderRow,
667
+ Content: PageHeaderContent,
668
+ Title: PageHeaderTitle,
669
+ Description: PageHeaderDescription,
670
+ Meta: PageHeaderMeta,
671
+ Stats: PageHeaderStats,
672
+ Stat: PageHeaderStat,
673
+ Actions: PageHeaderActions,
674
+ });