/** * PageHeader — the top of a profile screen: a cover, the face over it, who it * belongs to, and what you can do about them. * * Every social app opens a profile the same way, and rebuilding it per screen * is how the cover ends up a different height on each one. The parts here are * the pieces that arrangement is made of, and the two variants are the two * places it lives: `card`, a self-contained surface in a scroll of other * cards, and `page`, the header of the screen itself. * * ```tsx * * * * * Olivia Rhye * @oliviarhye * * * * * * ``` * * The cover in `card` is held off the card's edges and rounded on its top * corners only, so its bottom edge meets the content rather than floating * above it. The radius pair is `rounded-3xl` on the card and `rounded-2xl` on * the cover, one step apart on the theme's own scale — which is the same 8 * points the card is padded by, so the two curves stay concentric without a * hardcoded number that would be wrong in half the themes. * * The avatar lifts itself over the cover's bottom edge, and it works out * whether to: the root looks for a `PageHeader.Cover` among its children and * says so through context, because a header with no cover has nothing to * overlap and an avatar that lifted anyway would hang off the top of it. * * The ring around the face is drawn in the surface behind it — the card, or * the page — rather than in a border colour, so the face reads as punched out * of the cover instead of outlined on top of it. */ import { Children, cloneElement, createContext, forwardRef, isValidElement, useContext, useMemo, type ReactNode, } from 'react'; import { Image, StyleSheet, View, type ImageSourcePropType, type PressableProps, type Text as RNText, type ViewProps, } from 'react-native'; import { LinearGradient } from 'expo-linear-gradient'; import { tv, type VariantProps } from 'tailwind-variants'; import { useCSSVariable } from 'uniwind'; import { AnimatedPressable } from '../../primitives/animated-pressable'; import { Text, type TextProps, textChildren } from '../../primitives/text'; import { cn } from '../../utils/cn'; import { BadgeCheckIcon, IconColorProvider } from '../../icons'; import { Avatar, AVATAR_SIZE_POINTS, type AvatarProps, type AvatarSizeName } from '../avatar'; /** Height of a cover that is not given one. Two lines of nothing, and enough * of a band for a face to sit half over. */ const COVER_HEIGHT = 120; /** Points of surface drawn around the face. */ const AVATAR_RING = 4; /** The rosette, as a share of the face it sits on. */ const BADGE_RATIO = 0.3; /** The cover gradient, when no image is given. Both are series tokens, so an * app that puts its charts on brand puts this on brand with them. */ const GRADIENT = ['--color-chart-2', '--color-chart-5'] as const; const GRADIENT_FALLBACK = ['#3b82f6', '#8b5cf6'] as const; const pageHeaderVariants = tv({ slots: { root: 'w-full', cover: 'overflow-hidden bg-muted', avatar: 'self-start', ring: 'overflow-hidden rounded-full', row: 'flex-row items-center gap-5', content: 'gap-1', meta: 'flex-row items-center gap-1.5', stats: 'flex-row items-center', actions: 'flex-row items-center gap-2', }, variants: { variant: { // `overflow-hidden` clips the cover's own square bottom corners to the // card's radius on the way down; `p-2` is what holds the cover off the // card's edges, and is the difference between the two radii above. card: { root: 'overflow-hidden rounded-3xl border border-border bg-card p-2', cover: 'rounded-t-2xl', ring: 'border-card', avatar: 'px-3', row: 'px-3', content: 'px-3 pt-3', actions: 'px-3 pb-2 pt-4', }, // No surface of its own, and the cover runs to the screen's edges. page: { root: 'bg-background', ring: 'border-background', avatar: 'px-4', row: 'px-4', content: 'px-4 pt-3', actions: 'px-4 pb-4 pt-4', }, }, align: { start: {}, center: { avatar: 'self-center', row: 'justify-center', content: 'items-center', meta: 'justify-center', stats: 'justify-center', actions: 'justify-center', }, }, }, defaultVariants: { variant: 'card', align: 'center', }, }); type PageHeaderVariantProps = VariantProps; /** Where the header lives: a card in a scroll, or the top of the screen. */ export type PageHeaderVariant = NonNullable; /** Which edge the face and the text line up on. */ export type PageHeaderAlign = NonNullable; interface PageHeaderContextValue { variant: PageHeaderVariant; align: PageHeaderAlign; hasCover: boolean; } const PageHeaderContext = createContext(undefined); function usePageHeader(component: string): PageHeaderContextValue { const context = useContext(PageHeaderContext); if (!context) throw new Error(`${component} must be used within a `); return context; } /** * True inside a `PageHeader.Row`. The avatar carries the body's inset and its * own alignment when it stands alone, and neither when something else is * already holding the row it sits in. */ const PageHeaderRowContext = createContext(false); /** How a stats row reads its numbers out. */ const PageHeaderStatsContext = createContext('stacked'); export interface PageHeaderProps extends ViewProps { className?: string; children?: ReactNode; } export interface PageHeaderRootProps extends PageHeaderProps { /** * `card` is a surface of its own, with the cover held off its edges. `page` * drops the surface and lets the cover run to the screen's edges. */ variant?: PageHeaderVariant; /** * Which edge the face, the text and the actions line up on. `center` is the * profile card; `start` is the screen header, where the name is the first * thing on the line rather than the middle of it. */ align?: PageHeaderAlign; } const PageHeaderRoot = forwardRef( ({ className, variant = 'card', align = 'center', children, ...props }, ref) => { // Whether the avatar has anything to overlap. Read off the children rather // than asked for, because a caller who has written a cover should not also // have to say that they have. const hasCover = Children.toArray(children).some( (child) => isValidElement(child) && child.type === PageHeaderCover ); const context = useMemo( () => ({ variant, align, hasCover }), [variant, align, hasCover] ); return ( {children} ); } ); PageHeaderRoot.displayName = 'PageHeader'; export interface PageHeaderCoverProps extends ViewProps { className?: string; /** The banner. Left out, the cover draws a gradient instead. */ source?: ImageSourcePropType; /** How tall the band is. */ height?: number; /** * The gradient, when there is no image. Two colours or more, as real colour * strings — this is painted rather than classed. */ colors?: readonly [string, string, ...string[]]; /** What the banner shows. Left out, it is treated as decoration. */ alt?: string; children?: ReactNode; } /** * The banner. An image when given one, a gradient when not — a header with no * banner still has to read as a header, and an empty band reads as a bug. */ const PageHeaderCover = forwardRef( ({ className, source, height = COVER_HEIGHT, colors, alt, style, children, ...props }, ref) => { const { variant } = usePageHeader('PageHeader.Cover'); const from = useCSSVariable(GRADIENT[0]); const to = useCSSVariable(GRADIENT[1]); // `useCSSVariable` can hand back something that is not a colour string, and // a gradient given one paints nothing at all. const ramp = colors ?? ([ typeof from === 'string' ? from : GRADIENT_FALLBACK[0], typeof to === 'string' ? to : GRADIENT_FALLBACK[1], ] as const); return ( {source ? ( ) : ( )} {children} ); } ); PageHeaderCover.displayName = 'PageHeader.Cover'; export interface PageHeaderAvatarProps extends Omit { className?: string; /** How big the face is. */ size?: AvatarSizeName; /** * What goes in the ring instead of a face — a logo, a monogram, a live * thumbnail. It fills the ring, so give it its own background and padding. */ children?: ReactNode; /** Draws the verification rosette in the face's bottom corner. */ verified?: boolean; /** * Anything else for that corner — a camera button, a presence dot, a "+". * Wins over `verified`. */ badge?: ReactNode; /** * Whether the face lifts over the cover's bottom edge. Set by the presence * of a `PageHeader.Cover`; pass it to override that either way. */ overlap?: boolean; } /** * The face, in a ring of whatever surface is behind it. * * The badge is drawn here rather than with `Avatar.Badge` because that one is * pinned to the top corner, where an unread count belongs. A verification mark * belongs at the bottom, beside the name it is vouching for. */ const PageHeaderAvatar = forwardRef( ( { className, size = 'xl', verified, badge, overlap, source, fallback, imageProps, children, style, ...props }, ref ) => { const { variant, align, hasCover } = usePageHeader('PageHeader.Avatar'); const inRow = useContext(PageHeaderRowContext); const slots = pageHeaderVariants({ variant, align }); const primary = useCSSVariable('--color-primary'); const onPrimary = useCSSVariable('--color-primary-foreground'); const diameter = AVATAR_SIZE_POINTS[size] ?? AVATAR_SIZE_POINTS.xl; const lifts = overlap ?? hasCover; const mark = badge ?? (verified ? ( ) : null); return ( {/* Two views, because the ring clips. A badge hung on the clipping one would be cut in half by the circle it is meant to sit against. */} {children ?? ( )} {mark ? {mark} : null} ); } ); PageHeaderAvatar.displayName = 'PageHeader.Avatar'; export interface PageHeaderRowProps extends PageHeaderProps { children?: ReactNode; } /** * The face, and whatever sits beside it. For the profile that puts its counts * next to the picture rather than under the name. */ const PageHeaderRow = forwardRef( ({ className, children, ...props }, ref) => { const { variant, align } = usePageHeader('PageHeader.Row'); return ( {children} ); } ); PageHeaderRow.displayName = 'PageHeader.Row'; export interface PageHeaderContentProps extends PageHeaderProps { children?: ReactNode; } /** The text block: the name, the handle, and anything under them. */ const PageHeaderContent = forwardRef( ({ className, children, ...props }, ref) => { const { variant, align } = usePageHeader('PageHeader.Content'); return ( {textChildren(children)} ); } ); PageHeaderContent.displayName = 'PageHeader.Content'; /** Whose page it is. Announces itself as a heading. */ const PageHeaderTitle = forwardRef(({ className, ...props }, ref) => ( )); PageHeaderTitle.displayName = 'PageHeader.Title'; /** The handle, the email, the bio — the quiet line under the name. */ const PageHeaderDescription = forwardRef(({ className, ...props }, ref) => ( )); PageHeaderDescription.displayName = 'PageHeader.Description'; export interface PageHeaderMetaProps extends PageHeaderProps { /** A glyph before the line. Takes the muted colour without being told. */ icon?: ReactNode; children?: ReactNode; } /** * One fact about the account — a link, a location, the month it was opened. */ const PageHeaderMeta = forwardRef( ({ className, icon, children, ...props }, ref) => { const { variant, align } = usePageHeader('PageHeader.Meta'); const muted = useCSSVariable('--color-muted-foreground'); return ( {icon ? ( {icon} ) : null} {textChildren(children, (text) => ( {text} ))} ); } ); PageHeaderMeta.displayName = 'PageHeader.Meta'; /** How a stats row reads its numbers out. */ export type PageHeaderStatsLayout = 'stacked' | 'inline'; export interface PageHeaderStatsProps extends PageHeaderProps { /** * `stacked` puts the label under the figure, for a row of counts read as a * set. `inline` runs them together — "533 Followers" — for counts read as * part of a sentence. */ layout?: PageHeaderStatsLayout; /** Rule between each count and the next. */ divided?: boolean; children?: ReactNode; } /** * The row of counts. * * Centred and stacked, the counts take equal widths across the full row. Left * to their content they are centred as a block but not as figures: "Followers" * is twice the width of "Posts", so the middle count sits off the centre line * the name and the buttons are on, and the row reads as very slightly wrong * without it being obvious why. */ const PageHeaderStats = forwardRef( ({ className, layout = 'stacked', divided, children, ...props }, ref) => { const { variant, align } = usePageHeader('PageHeader.Stats'); const stacked = layout === 'stacked'; const measured = stacked && align === 'center'; // React Native has no `:first-child`, so the rule between two counts // cannot be a style rule. Place it on every count but the first. const items = divided || measured ? Children.map(children, (child, index) => { if (!isValidElement(child)) return child; if (child.type !== PageHeaderStat) return child; const next: Partial = {}; if (divided && child.props.divided === undefined) { next.divided = index > 0; } if (measured) { next.className = cn('flex-1', child.props.className); } return cloneElement(child, next); }) : children; return ( {items} ); } ); PageHeaderStats.displayName = 'PageHeader.Stats'; export interface PageHeaderStatProps extends Omit { className?: string; /** The figure. */ value: ReactNode; /** What it counts. */ label: ReactNode; /** Rule before this count. `PageHeader.Stats` sets it; pass it to override. */ divided?: boolean; } /** * One count. Announced as a single thing — "533 Followers" — because a figure * and its label read apart are two pieces of nothing. */ const PageHeaderStat = forwardRef( ({ className, value, label, divided, onPress, accessibilityLabel, ...props }, ref) => { const layout = useContext(PageHeaderStatsContext); const inline = layout === 'inline'; const spoken = accessibilityLabel ?? (isSpeakable(value) && isSpeakable(label) ? `${value} ${label}` : undefined); const classes = cn( inline ? 'flex-row items-center gap-1' : 'items-center gap-0.5', divided && 'border-s border-border ps-4', className ); const body = ( <> {value} {label} ); if (!onPress) { return ( {body} ); } return ( {body} ); } ); PageHeaderStat.displayName = 'PageHeader.Stat'; /** Whether a node can be read into an accessibility label as it stands. */ function isSpeakable(node: ReactNode): node is string | number { return typeof node === 'string' || typeof node === 'number'; } export interface PageHeaderActionsProps extends PageHeaderProps { children?: ReactNode; } /** * What you can do about the account. Give the buttons `className="flex-1"` for * the pair that splits the width evenly. */ const PageHeaderActions = forwardRef( ({ className, children, ...props }, ref) => { const { variant, align } = usePageHeader('PageHeader.Actions'); return ( {textChildren(children)} ); } ); PageHeaderActions.displayName = 'PageHeader.Actions'; export const PageHeader = Object.assign(PageHeaderRoot, { Cover: PageHeaderCover, Avatar: PageHeaderAvatar, Row: PageHeaderRow, Content: PageHeaderContent, Title: PageHeaderTitle, Description: PageHeaderDescription, Meta: PageHeaderMeta, Stats: PageHeaderStats, Stat: PageHeaderStat, Actions: PageHeaderActions, });