/** * FunnelChart — how many were left at each step, and where the rest went. * * ```tsx * * * * * * ``` * * ## What it is, against the bars next door * * A bar chart compares quantities that need not have anything to do with each * other. A funnel makes a much stronger claim: every stage is a *subset of the * one above it*, in order, and the reader is being shown where a population * drained away. That is why the stages are not sorted, why nothing is * normalised to a total, and why the interesting number is not any stage's * value but the ratio between two of them. * * It follows that the order is the caller's and never the chart's. Stages are * steps in a process — a signup, a checkout, a support queue — and reordering * them by size would destroy the only thing the chart is asserting. A stage * larger than the one above it is therefore drawn as given, wider than its * parent, because that is a real and visible data problem and hiding it would * be the chart lying to save face. * * ## Drawing * * One ribbon running across the card, not a stack of blocks. The stages divide * the width between them, and each is a band symmetrical about the centre line * — as tall as its value where it starts and as tall as the next stage's where * it ends. The sides are curves that reach past each other, so consecutive * bands meet flush and the whole run reads as a single narrowing channel rather * than a row of separate shapes. The slope across a band is the drop. * * Each band is drawn several times over, concentrically: a wide faint ring on * the outside through to a tight near-solid core. It is a halo, and it does two * jobs. It gives the ribbon an edge that falls off rather than stopping dead; * and it leaves a band of low-opacity fill above and below the core that text * can sit on and still be read. * * The stages arrive one after another rather than together, each growing out of * the centre line. A funnel is a sequence, and a sequence that assembles in its * own order tells the reader which way to read it before they have read a word. * * ## Reading it * * The readings are laid out around the ribbon rather than crowded onto one * line: the count above the band, the name below it, and the conversion in a * pill in the middle of the band itself. Three readings, three places, none of * them competing for the same space — which is what keeps a stage name whole * under a column narrow enough to fit five of them on a phone, and what keeps * the pill legible whatever the ribbon is doing underneath it. * * ## Colour * * One hue, fading along the run, rather than a colour per stage. A funnel's * stages are one quantity at successive moments, not five unrelated series, and * five hues would say they were. A stage can still be given its own `color` * when it means something — the step where the money is taken, the one being * discussed — and that one is drawn at full strength. */ import { Children, createContext, forwardRef, isValidElement, useContext, useEffect, useImperativeHandle, useMemo, useRef, useState, type ReactNode, } from 'react'; import { Pressable, View, type LayoutChangeEvent, type ViewProps } from 'react-native'; import Animated, { Easing, useAnimatedProps, useDerivedValue, useReducedMotion, useSharedValue, withTiming, type SharedValue, } from 'react-native-reanimated'; import Svg, { G, Path } from 'react-native-svg'; import { useCSSVariable } from 'uniwind'; import { useDirection } from '../../hooks/use-direction'; import { Text } from '../../primitives/text'; import { compactNumber, ribbonPath, useSeriesColor } from '../../utils/chart'; import { cn } from '../../utils/cn'; import { useSkeletonHandoff } from '../../hooks/use-skeleton-handoff'; const AnimatedPath = Animated.createAnimatedComponent(Path); /** Milliseconds for a stage to dim as another is selected. */ const SELECT_DURATION = 180; /** How far the hue has faded by the end of the run. */ const FADE = 0.35; /** Stages' worth of room kept for a loading chart that has no data yet. */ const SKELETON_STAGES = 4; /** Rings drawn per stage, unless the caller asks for another number. */ const DEFAULT_LAYERS = 3; /** How far in the innermost ring is drawn from the outermost. */ const RING_INSET = 0.35; /** Opacity of the outermost ring, and of the innermost. */ const RING_FAINTEST = 0.18; const RING_STRONGEST = 0.83; /** How much further the rings spread when their stage is selected. */ const SELECT_SPREAD = 0.12; /** Milliseconds between one stage starting to grow and the next. */ const STAGGER = 90; /** How tall the run is drawn when the caller does not say. */ const DEFAULT_HEIGHT = 200; /** * How far the sides' control points reach along a band, as a fraction of it. * * Past a half they overshoot each other, which is what flattens the ends of the * join and puts the whole slope in the middle — the difference between a run * that looks poured and one that looks folded. */ const CURVE = 0.55; /** * The share of the height kept above the ribbon for the count, and below it for * the name. The ribbon takes what is left and sits centred in it. * * Equal, because what they hold is equal: both are one line. So the widest * stage reaches exactly to the text at both ends of the band, with nothing * creeping under the words and no strip of nothing between them and the shape. */ const TEXT_BAND = 0.15; /** Where a child is drawn: inside the SVG, over it, above it, or under it. */ type Slot = 'svg' | 'overlay' | 'header' | 'footer'; /** Whether the chart is showing data or waiting for it. */ export type FunnelChartStatus = 'loading' | 'ready'; /** Whether the sides of a band are drawn as curves or as straight diagonals. */ export type FunnelEdges = 'curved' | 'straight'; /** One step of the process. */ export interface FunnelDatum { /** Name of the step, for the label, the legend and the accessibility label. */ label: string; /** How many were left at it. Negatives are treated as zero. */ value: number; /** Explicit colour, drawn at full strength instead of the faded hue. */ color?: string; } /** One stage's band: where it sits along the run and how tall it is at each end. */ interface Band { /** Where it starts along the run, in points. */ offset: number; /** How wide it is, in points. */ length: number; /** Half-height where it starts, in points. */ head: number; /** Half-height where it ends — the next stage's, or its own if it is last. */ tail: number; } interface FunnelChartContextValue { data: FunnelDatum[]; /** The run's width, in points. */ width: number; /** The run's height, in points. */ height: number; /** Room kept above the ribbon and below it, in points. */ textBand: number; /** The ribbon's centre line, in points from the top. */ middle: number; bands: Band[]; /** Each stage's share of the *first* stage, 0 to 1. */ shares: number[]; /** Each stage's share of the one above it, 0 to 1. The first is 1. */ steps: number[]; colors: string[]; /** How far the hue has faded at each stage. `1` where a colour was given. */ strengths: number[]; layers: number; curve: number; /** `0` to `1` across the whole staggered entrance. */ reveal: SharedValue; /** Where in that each stage's own growth begins and ends. */ windows: { from: number; to: number }[]; status: FunnelChartStatus; activeIndex: number; setActiveIndex: (index: number) => void; } const FunnelChartContext = createContext(null); function useChart(component: string): FunnelChartContextValue { const context = useContext(FunnelChartContext); if (!context) { throw new Error(`${component} must be used within a `); } return context; } /** The selected stage and how it converted, for something drawn inside the chart. */ export function useFunnelChart() { const { data, shares, steps, activeIndex } = useChart('useFunnelChart'); return { activeIndex, activeStage: activeIndex >= 0 ? (data[activeIndex] ?? null) : null, /** Its share of the first stage, 0 to 1. */ activeShare: activeIndex >= 0 ? (shares[activeIndex] ?? 0) : 0, /** Its share of the stage above it, 0 to 1. */ activeStep: activeIndex >= 0 ? (steps[activeIndex] ?? 0) : 0, }; } export interface FunnelChartProps extends ViewProps { className?: string; /** The steps, in the order they happen. Never reordered. */ data: FunnelDatum[]; /** * How tall the run is drawn, in points. * * The run is as wide as it is given and as deep as this: the width is the * card's, but nothing in the data says how far the ribbon should taper * through, so it is a decision rather than a measurement. */ height?: number; /** * How wide one stage is, in points. * * Left unset the stages divide the width between them, which is nearly always * what a run across a card wants. Worth setting only to make a run stop short * of the edge. */ stageSize?: number; /** Space between one stage and the next, in points. */ gap?: number; /** * Concentric rings drawn per stage, faint and wide on the outside through to * a near-solid core. `1` draws the band once, flat. */ layers?: number; /** Whether the sides of a band are curves or straight diagonals. */ edges?: FunnelEdges; /** * The shortest a non-zero stage is drawn, as a share of the tallest. * * A stage worth a fifth of a percent of the first is a hairline: it reads as * missing rather than as small, and "missing" is a different claim. The floor * is only applied to stages that have something in them — a genuine zero is * drawn as nothing, because there it is the truth. */ minWidth?: number; /** The funnel's hue. Defaults to the first chart token. */ color?: string; /** Milliseconds for one stage to grow. */ animationDuration?: number; /** Milliseconds between one stage starting and the next. `0` for all at once. */ staggerDelay?: number; /** `loading` draws one plain muted ribbon until the data arrives. */ status?: FunnelChartStatus; /** Selected stage. Leave unset to let the chart track it. */ activeIndex?: number; /** Fires with the selected stage, or `-1` when the selection is cleared. */ onActiveIndexChange?: (index: number) => void; children?: ReactNode; } /** Imperative handle: re-run the entrance, for a "replay" control. */ export interface FunnelChartHandle { replay: () => void; } const FunnelChartRoot = forwardRef( function FunnelChartRoot( { className, data, height = DEFAULT_HEIGHT, stageSize, gap = 4, layers = DEFAULT_LAYERS, edges = 'curved', minWidth = 0.1, color, animationDuration = 700, staggerDelay = STAGGER, status = 'ready', activeIndex: activeIndexProp, onActiveIndexChange, children, ...props }, ref ) { const [width, setWidth] = useState(0); const [internalActive, setInternalActive] = useState(-1); const reveal = useSharedValue(0); const reducedMotion = useReducedMotion(); const direction = useDirection(); const controlled = activeIndexProp !== undefined; const activeIndex = controlled ? activeIndexProp : internalActive; const setActiveIndex = useMemo( () => (index: number) => { if (!controlled) setInternalActive(index); onActiveIndexChange?.(index); }, [controlled, onActiveIndexChange] ); const count = data.length; /* * A funnel that is still loading usually has no data at all, and no stages * to divide the width between. So an empty loading chart is given a * plausible number of them — which is also what stops the card from jumping * the moment the data lands. */ const stages = count || (status === 'loading' ? SKELETON_STAGES : 0); const spacing = Math.max(gap, 0); /* * The stages divide the width unless they are given a size. A run sized in * points would stop somewhere short of the edge and leave the rest of the * card blank, which is the one thing a chart across a card must not do. */ const size = stageSize ?? (stages > 0 ? Math.max(0, (width - (stages - 1) * spacing) / stages) : 0); /** The largest value in the run — see the note about broken funnels above. */ const peak = useMemo( () => data.reduce((most, stage) => Math.max(most, Math.max(0, stage.value)), 0), [data] ); const shares = useMemo(() => { const first = Math.max(0, data[0]?.value ?? 0); return data.map((stage) => first > 0 ? Math.max(0, stage.value) / first : 0 ); }, [data]); const steps = useMemo( () => data.map((stage, index) => { if (index === 0) return 1; const previous = Math.max(0, data[index - 1]?.value ?? 0); return previous > 0 ? Math.max(0, stage.value) / previous : 0; }), [data] ); /* * The run reads leading-edge first like the text under it, and SVG has no * leading edge — so under a right-to-left layout the bands are laid from * the other end, and each one's two heights swap with them. */ const mirrored = direction === 'rtl'; /* * The ribbon takes whatever the two text bands leave and sits centred in it, * so the tallest stage reaches exactly to the count above and the name * below: no strip of nothing between the shape and the words, and no shape * creeping under them. */ const textBand = height * TEXT_BAND; const reach = Math.max(0, height / 2 - textBand); const middle = height / 2; const bands = useMemo(() => { if (!count || width <= 0 || reach <= 0) return []; const floor = Math.max(0, Math.min(minWidth, 1)); const extents = data.map((stage) => { const value = Math.max(0, stage.value); if (value <= 0 || peak <= 0) return 0; // The floor is a floor, not a rescale: a stage already above it keeps // its true height, so the heights of the stages that matter still read // against each other exactly. return reach * Math.max(value / peak, floor); }); return extents.map((extent, index) => { const offset = index * (size + spacing); const next = extents[index + 1] ?? extent; return { offset: mirrored ? width - offset - size : offset, length: size, head: mirrored ? next : extent, tail: mirrored ? extent : next, }; }); }, [count, width, reach, data, peak, minWidth, size, spacing, mirrored]); const hue = useSeriesColor(color, 1); const colors = useMemo( () => data.map((stage) => stage.color ?? hue), [data, hue] ); const strengths = useMemo( () => data.map((stage, index) => { if (stage.color) return 1; if (count < 2) return 1; return 1 - (index / (count - 1)) * FADE; }), [data, count] ); /* * One clock for the whole entrance, with each stage given the slice of it * that it grows in. A shared value per stage would be the same animation * played `n` times and `n` more things for a replay to have to find. */ const stagger = Math.max(0, staggerDelay); const total = animationDuration + Math.max(count - 1, 0) * stagger; const windows = useMemo( () => data.map((_, index) => { const from = index * stagger; return { from: total > 0 ? from / total : 0, to: total > 0 ? (from + animationDuration) / total : 1, }; }), [data, stagger, animationDuration, total] ); const playReveal = useMemo( () => () => { if (reducedMotion) { reveal.value = 1; return; } reveal.value = 0; // Linear, because the shaping is per stage: each one eases inside its // own window, and easing the clock as well would ease it twice. reveal.value = withTiming(1, { duration: total, easing: Easing.linear }); }, [reducedMotion, total, reveal] ); const loading = status === 'loading'; const revealed = useRef(false); useEffect(() => { if (loading) { revealed.current = false; reveal.value = 0; return; } if (revealed.current || !bands.length) return; revealed.current = true; playReveal(); }, [loading, bands.length, playReveal, reveal]); useImperativeHandle(ref, () => ({ replay: playReveal }), [playReveal]); // The caller's own `onLayout` is not forwarded from here: it is already on // the outer view, and the box it wants is the whole chart's rather than the // run's — which are different heights the moment there is a header. const onLayout = (event: LayoutChangeEvent) => { const next = Math.round(event.nativeEvent.layout.width); if (next !== width) setWidth(next); }; const context = useMemo( () => ({ data, width, height, textBand, middle, bands, shares, steps, colors, strengths, layers: Math.max(1, Math.round(layers)), curve: edges === 'curved' ? CURVE : 0, reveal, windows, status, activeIndex, setActiveIndex, }), [ data, width, height, textBand, middle, bands, shares, steps, colors, strengths, layers, edges, reveal, windows, status, activeIndex, setActiveIndex, ] ); const slots: Record = { svg: [], overlay: [], header: [], footer: [], }; Children.forEach(children, (child, index) => { if (!isValidElement(child)) return; const slot = (child.type as { slot?: Slot }).slot ?? 'overlay'; slots[slot in slots ? slot : 'overlay'].push( {child} ); }); return ( {slots.header} {/* * The run is measured on its own view rather than the outer one, so a * header or a legend cannot change how wide the funnel thinks it is. */} {width > 0 && height > 0 ? ( <> {slots.svg} {/* * Labels sit over the SVG rather than inside it: they are text, * and SVG text ignores the platform's text scaling and the * theme's font. */} {slots.overlay} ) : null} {slots.footer} ); } ); FunnelChartRoot.displayName = 'FunnelChart'; function ChildSlot({ children }: { children: ReactNode }) { return <>{children}; } export interface FunnelChartStagesProps { /** Opacity of the stages that are not selected, once one is. */ dimOpacity?: number; } /** * Every stage, drawn in the order the data lists them. * * One part rather than one per datum. A stage's near edge is the previous * stage's far edge, so they cannot be configured apart without the ribbon * coming apart with them — a funnel whose third stage could be given its own * height would be a funnel drawing a shape that is not in the data. */ function FunnelChartStages({ dimOpacity = 0.3 }: FunnelChartStagesProps) { const { data, bands, colors, strengths, middle, layers, curve, reveal, windows, status, shares, activeIndex, setActiveIndex, } = useChart('FunnelChart.Stages'); if (status === 'loading' || !bands.length) return null; return ( {bands.map((band, index) => { const datum = data[index]; const window = windows[index]; if (!datum || !window || (band.head <= 0 && band.tail <= 0)) return null; return ( = 0 && activeIndex !== index} dimOpacity={dimOpacity} label={datum.label} value={datum.value} percent={Math.round((shares[index] ?? 0) * 100)} onPress={() => setActiveIndex(activeIndex === index ? -1 : index)} /> ); })} ); } FunnelChartStages.displayName = 'FunnelChart.Stages'; FunnelChartStages.slot = 'svg' as const; /** * One stage, drawn as a stack of concentric rings. * * Every ring is rebuilt on the UI thread each frame it is moving. They grow out * of the centre line rather than being scaled from nothing, because a scale * would take the curve's control points with it and the sides would flex on the * way in; growing the heights leaves the shape's geometry alone and only ever * changes how far it reaches. */ function Stage({ band, middle, curve, layers, fill, strength, reveal, window, selected, dimmed, dimOpacity, label, value, percent, onPress, }: { band: Band; middle: number; curve: number; layers: number; fill: string; strength: number; reveal: SharedValue; window: { from: number; to: number }; selected: boolean; dimmed: boolean; dimOpacity: number; label: string; value: number; percent: number; onPress: () => void; }) { const dim = useDerivedValue(() => withTiming(dimmed ? 1 : 0, { duration: SELECT_DURATION }) ); const spread = useDerivedValue(() => withTiming(selected ? 1 : 0, { duration: SELECT_DURATION }) ); return ( {Array.from({ length: layers }, (_, ring) => ( ))} ); } function Ring({ band, middle, curve, fill, scale, opacity, spreadBy, reveal, window, spread, dim, dimOpacity, }: { band: Band; middle: number; curve: number; fill: string; scale: number; opacity: number; spreadBy: number; reveal: SharedValue; window: { from: number; to: number }; spread: SharedValue; dim: SharedValue; dimOpacity: number; }) { const { offset, length, head, tail } = band; const { from, to } = window; const animatedProps = useAnimatedProps(() => { const range = to - from; const raw = range > 0 ? (reveal.value - from) / range : 1; const progress = raw < 0 ? 0 : raw > 1 ? 1 : raw; // Ease out cubic: fast to most of the way, then settling. Written out // rather than called, because an easing from the animation library is not // a worklet and this runs on the UI thread. const eased = 1 - (1 - progress) * (1 - progress) * (1 - progress); const reach = scale * eased * (1 + spread.value * spreadBy); return { d: ribbonPath(offset, length, head * reach, tail * reach, middle, curve), fillOpacity: opacity * (1 - dim.value * (1 - dimOpacity)), }; }); return ; } export interface FunnelChartSkeletonProps { color?: string; } /** * The loading state: one plain ribbon over the whole run, undivided. * * Deliberately undivided. Placeholder stages would be an invented drop-off, and * a reader has no way to tell an invented one from a real one until it changes * under them — which is worse than showing nothing, because it is showing * something wrong. */ function FunnelChartSkeleton({ color }: FunnelChartSkeletonProps) { const { width, height, middle, textBand, curve, status } = useChart('FunnelChart.Skeleton'); const token = useCSSVariable('--color-skeleton'); const fill = color ?? (typeof token === 'string' ? token : 'rgba(128,128,128,0.2)'); // Held through the fade rather than to the frame the data lands, so the plain // taper dissolves under the stages growing across it instead of leaving a // blank panel between the two. const { mounted, opacity } = useSkeletonHandoff(status === 'loading'); const animatedProps = useAnimatedProps(() => ({ opacity: opacity.value })); if (!mounted || width <= 0 || height <= 0) return null; const extent = Math.max(0, height / 2 - textBand); return ( ); } FunnelChartSkeleton.displayName = 'FunnelChart.Skeleton'; FunnelChartSkeleton.slot = 'svg' as const; /** Which ratio a stage's pill reports. */ export type FunnelShare = 'previous' | 'top' | 'none'; export interface FunnelChartLabelsProps { className?: string; /** Format the count. Defaults to a compact number. */ formatValue?: (value: number, stage: FunnelDatum) => string; /** Format the conversion in the pill. Defaults to a whole percent. */ formatShare?: (share: number, stage: FunnelDatum) => string; /** * Which conversion the pill reports. `top` is the share of the first stage, * which every stage has and which reads along the run as one falling series. * `previous` is the drop from the stage above — the step-by-step reading, * where the first stage has nothing above it and so carries no pill. */ share?: FunnelShare; /** Show the count above the ribbon. */ showValue?: boolean; /** Show the stage's name under the ribbon. */ showLabel?: boolean; } /** * The readings, arranged around the ribbon: the count above the band, the name * under it, and the conversion in a pill on the band itself. * * Three places rather than one line, and that is the whole point of it. Put a * name, a count and a percentage together on one row and the row is as wide as * all three — so at the width a phone actually has, the name is the one that * gives way and the reader is left with "Checkout st…" against a number. Split * around the band, each reading has the stage's full column to itself. * * The pill is a filled chip rather than bare text because it is the one reading * that sits *on* the ribbon, where the fill behind it is the same token family * as the text would be. Punched out of its own background, it reads whatever * the band is doing underneath. * * The columns are pressable rather than the shape alone: a two-percent stage is * a sliver, and the column it lives in is a target. */ function FunnelChartLabels({ className, formatValue, formatShare, share = 'top', showValue = true, showLabel = true, }: FunnelChartLabelsProps) { const { data, bands, shares, steps, textBand, status, activeIndex, setActiveIndex } = useChart('FunnelChart.Labels'); if (status === 'loading' || !bands.length) return null; const format = formatValue ?? ((value: number) => compactNumber(value)); const formatPercent = formatShare ?? ((ratio: number) => `${Math.round(ratio * 100)}%`); return ( <> {bands.map((band, index) => { const datum = data[index]; if (!datum) return null; const ratio = share === 'top' ? shares[index] : steps[index]; const pill = share === 'none' || (share === 'previous' && index === 0) ? null : formatPercent(ratio ?? 0, datum); const dimmed = activeIndex >= 0 && activeIndex !== index; const value = format(datum.value, datum); return ( setActiveIndex(activeIndex === index ? -1 : index)} style={{ position: 'absolute', left: band.offset, width: band.length, top: 0, bottom: 0, opacity: dimmed ? 0.5 : 1, }} className={cn('items-center px-1', className)} > {/* * The two strips are the same share of the height the ribbon leaves * them, so the text ends exactly where the shape starts rather than * at a padding somebody guessed. */} {showValue ? ( {value} ) : null} {pill ? ( {pill} ) : null} {showLabel ? ( {datum.label} ) : null} ); })} ); } FunnelChartLabels.displayName = 'FunnelChart.Labels'; FunnelChartLabels.slot = 'overlay' as const; /** How the key under the run is arranged. */ export type FunnelLegendLayout = 'list' | 'inline'; export interface FunnelChartLegendProps extends ViewProps { className?: string; /** * `list` gives every stage a row of its own, with the names down one column * and the numbers down another. `inline` runs them together across the width * and wraps, which is the denser arrangement where the names are short. */ layout?: FunnelLegendLayout; /** Show each stage's reading beside its name. */ showValue?: boolean; /** Format the value in a `list` key. Defaults to a compact number. */ formatValue?: (value: number, stage: FunnelDatum) => string; } /** * A swatch, a name and a reading per stage, under the run. Pressable in the * same way the stages are. * * For a funnel drawn without `Labels` — a compact one on a dashboard card, * where the run is a shape and the reading is underneath it. * * A row each, by default. The stages of a funnel are a sequence, and a wrapped * centred line loses that: the reader gets a ragged block of names in which the * order is only implied by the order they happen to be read in, and a long * stage name breaks it across lines that no longer line up with anything. Down * a column the order is the order, and the numbers stack into a column of their * own that can be compared at a glance. */ function FunnelChartLegend({ className, layout = 'list', showValue = true, formatValue, ...props }: FunnelChartLegendProps) { const { data, shares, colors, strengths, activeIndex, setActiveIndex } = useChart('FunnelChart.Legend'); if (!data.length) return null; const list = layout === 'list'; const format = formatValue ?? ((value: number) => compactNumber(value)); return ( {data.map((stage, index) => { const percent = Math.round((shares[index] ?? 0) * 100); const dimmed = activeIndex >= 0 && activeIndex !== index; return ( setActiveIndex(activeIndex === index ? -1 : index)} style={{ opacity: dimmed ? 0.4 : 1 }} className={cn( 'flex-row items-center gap-1.5', list ? 'w-full' : 'max-w-full' )} > {stage.label} {showValue ? ( <> {/* * The value only earns its place in a list, where it lands in a * column with the others. Inline it would be a second number * loose in a wrapping line, and the share is the one worth * having there. */} {list ? ( {format(stage.value, stage)} ) : null} {percent}% ) : null} ); })} ); } FunnelChartLegend.displayName = 'FunnelChart.Legend'; FunnelChartLegend.slot = 'footer' as const; export interface FunnelChartHeaderProps extends ViewProps { className?: string; /** Small line above the value — what the funnel is of. */ title?: string; /** The readout. The largest thing on the card, and the first thing read. */ value?: string; /** One muted line under the value — a period, a comparison, a caveat. */ caption?: string; /** Trailing slot — a control, a badge, a range picker. */ children?: ReactNode; } /** * The strip above the run: what the funnel is of and what it reads. * * It belongs to the chart rather than to the card around it because it is about * the *stages* — the number changes as one is selected. The card's header is a * caption on the tray the chart sits in; this is the chart introducing itself. * * The value is not derived even though there is a first stage to derive it * from, because the formatting is not the chart's to guess: 41800 is a count, * a currency or a rate depending on what was counted. */ function FunnelChartHeader({ className, title, value, caption, children, ...props }: FunnelChartHeaderProps) { return ( {title ? ( {title} ) : null} {value ? ( {value} ) : null} {caption ? ( {caption} ) : null} {children ? {children} : null} ); } FunnelChartHeader.displayName = 'FunnelChart.Header'; FunnelChartHeader.slot = 'header' as const; export const FunnelChart = Object.assign(FunnelChartRoot, { Header: FunnelChartHeader, Stages: FunnelChartStages, Labels: FunnelChartLabels, Legend: FunnelChartLegend, Skeleton: FunnelChartSkeleton, });