panelui-native 0.57.0 → 0.60.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 (37) hide show
  1. package/README.md +4 -1
  2. package/lib/module/components/fab/index.js +63 -30
  3. package/lib/module/components/fab/index.js.map +1 -1
  4. package/lib/module/components/funnel-chart/index.js +828 -0
  5. package/lib/module/components/funnel-chart/index.js.map +1 -0
  6. package/lib/module/components/progress/index.js +67 -13
  7. package/lib/module/components/progress/index.js.map +1 -1
  8. package/lib/module/components/selection-mode/index.js +805 -0
  9. package/lib/module/components/selection-mode/index.js.map +1 -0
  10. package/lib/module/components/tabs/index.js +384 -341
  11. package/lib/module/components/tabs/index.js.map +1 -1
  12. package/lib/module/index.js +2 -0
  13. package/lib/module/index.js.map +1 -1
  14. package/lib/module/utils/chart.js +36 -0
  15. package/lib/module/utils/chart.js.map +1 -1
  16. package/lib/typescript/src/components/fab/index.d.ts +11 -5
  17. package/lib/typescript/src/components/fab/index.d.ts.map +1 -1
  18. package/lib/typescript/src/components/funnel-chart/index.d.ts +289 -0
  19. package/lib/typescript/src/components/funnel-chart/index.d.ts.map +1 -0
  20. package/lib/typescript/src/components/progress/index.d.ts +9 -1
  21. package/lib/typescript/src/components/progress/index.d.ts.map +1 -1
  22. package/lib/typescript/src/components/selection-mode/index.d.ts +498 -0
  23. package/lib/typescript/src/components/selection-mode/index.d.ts.map +1 -0
  24. package/lib/typescript/src/components/tabs/index.d.ts +64 -46
  25. package/lib/typescript/src/components/tabs/index.d.ts.map +1 -1
  26. package/lib/typescript/src/index.d.ts +3 -1
  27. package/lib/typescript/src/index.d.ts.map +1 -1
  28. package/lib/typescript/src/utils/chart.d.ts +19 -0
  29. package/lib/typescript/src/utils/chart.d.ts.map +1 -1
  30. package/package.json +1 -1
  31. package/src/components/fab/index.tsx +66 -31
  32. package/src/components/funnel-chart/index.tsx +1121 -0
  33. package/src/components/progress/index.tsx +64 -11
  34. package/src/components/selection-mode/index.tsx +1027 -0
  35. package/src/components/tabs/index.tsx +452 -413
  36. package/src/index.ts +29 -0
  37. package/src/utils/chart.ts +52 -0
@@ -0,0 +1,828 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * FunnelChart — how many were left at each step, and where the rest went.
5
+ *
6
+ * ```tsx
7
+ * <FunnelChart data={checkout}>
8
+ * <FunnelChart.Header title="Checkout" value="41,800" />
9
+ * <FunnelChart.Stages />
10
+ * <FunnelChart.Labels />
11
+ * </FunnelChart>
12
+ * ```
13
+ *
14
+ * ## What it is, against the bars next door
15
+ *
16
+ * A bar chart compares quantities that need not have anything to do with each
17
+ * other. A funnel makes a much stronger claim: every stage is a *subset of the
18
+ * one above it*, in order, and the reader is being shown where a population
19
+ * drained away. That is why the stages are not sorted, why nothing is
20
+ * normalised to a total, and why the interesting number is not any stage's
21
+ * value but the ratio between two of them.
22
+ *
23
+ * It follows that the order is the caller's and never the chart's. Stages are
24
+ * steps in a process — a signup, a checkout, a support queue — and reordering
25
+ * them by size would destroy the only thing the chart is asserting. A stage
26
+ * larger than the one above it is therefore drawn as given, wider than its
27
+ * parent, because that is a real and visible data problem and hiding it would
28
+ * be the chart lying to save face.
29
+ *
30
+ * ## Drawing
31
+ *
32
+ * One ribbon running across the card, not a stack of blocks. The stages divide
33
+ * the width between them, and each is a band symmetrical about the centre line
34
+ * — as tall as its value where it starts and as tall as the next stage's where
35
+ * it ends. The sides are curves that reach past each other, so consecutive
36
+ * bands meet flush and the whole run reads as a single narrowing channel rather
37
+ * than a row of separate shapes. The slope across a band is the drop.
38
+ *
39
+ * Each band is drawn several times over, concentrically: a wide faint ring on
40
+ * the outside through to a tight near-solid core. It is a halo, and it does two
41
+ * jobs. It gives the ribbon an edge that falls off rather than stopping dead;
42
+ * and it leaves a band of low-opacity fill above and below the core that text
43
+ * can sit on and still be read.
44
+ *
45
+ * The stages arrive one after another rather than together, each growing out of
46
+ * the centre line. A funnel is a sequence, and a sequence that assembles in its
47
+ * own order tells the reader which way to read it before they have read a word.
48
+ *
49
+ * ## Reading it
50
+ *
51
+ * The readings are laid out around the ribbon rather than crowded onto one
52
+ * line: the count above the band, the name below it, and the conversion in a
53
+ * pill in the middle of the band itself. Three readings, three places, none of
54
+ * them competing for the same space — which is what keeps a stage name whole
55
+ * under a column narrow enough to fit five of them on a phone, and what keeps
56
+ * the pill legible whatever the ribbon is doing underneath it.
57
+ *
58
+ * ## Colour
59
+ *
60
+ * One hue, fading along the run, rather than a colour per stage. A funnel's
61
+ * stages are one quantity at successive moments, not five unrelated series, and
62
+ * five hues would say they were. A stage can still be given its own `color`
63
+ * when it means something — the step where the money is taken, the one being
64
+ * discussed — and that one is drawn at full strength.
65
+ */
66
+ import { Children, createContext, forwardRef, isValidElement, useContext, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react';
67
+ import { Pressable, View } from 'react-native';
68
+ import Animated, { Easing, useAnimatedProps, useDerivedValue, useReducedMotion, useSharedValue, withTiming } from 'react-native-reanimated';
69
+ import Svg, { G, Path } from 'react-native-svg';
70
+ import { useCSSVariable } from 'uniwind';
71
+ import { useDirection } from "../../hooks/use-direction.js";
72
+ import { Text } from "../../primitives/text.js";
73
+ import { compactNumber, ribbonPath, useSeriesColor } from "../../utils/chart.js";
74
+ import { cn } from "../../utils/cn.js";
75
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
76
+ const AnimatedPath = Animated.createAnimatedComponent(Path);
77
+
78
+ /** Milliseconds for a stage to dim as another is selected. */
79
+ const SELECT_DURATION = 180;
80
+
81
+ /** How far the hue has faded by the end of the run. */
82
+ const FADE = 0.35;
83
+
84
+ /** Stages' worth of room kept for a loading chart that has no data yet. */
85
+ const SKELETON_STAGES = 4;
86
+
87
+ /** Rings drawn per stage, unless the caller asks for another number. */
88
+ const DEFAULT_LAYERS = 3;
89
+
90
+ /** How far in the innermost ring is drawn from the outermost. */
91
+ const RING_INSET = 0.35;
92
+
93
+ /** Opacity of the outermost ring, and of the innermost. */
94
+ const RING_FAINTEST = 0.18;
95
+ const RING_STRONGEST = 0.83;
96
+
97
+ /** How much further the rings spread when their stage is selected. */
98
+ const SELECT_SPREAD = 0.12;
99
+
100
+ /** Milliseconds between one stage starting to grow and the next. */
101
+ const STAGGER = 90;
102
+
103
+ /** How tall the run is drawn when the caller does not say. */
104
+ const DEFAULT_HEIGHT = 200;
105
+
106
+ /**
107
+ * How far the sides' control points reach along a band, as a fraction of it.
108
+ *
109
+ * Past a half they overshoot each other, which is what flattens the ends of the
110
+ * join and puts the whole slope in the middle — the difference between a run
111
+ * that looks poured and one that looks folded.
112
+ */
113
+ const CURVE = 0.55;
114
+
115
+ /**
116
+ * The share of the height kept above the ribbon for the count, and below it for
117
+ * the name. The ribbon takes what is left and sits centred in it.
118
+ *
119
+ * Equal, because what they hold is equal: both are one line. So the widest
120
+ * stage reaches exactly to the text at both ends of the band, with nothing
121
+ * creeping under the words and no strip of nothing between them and the shape.
122
+ */
123
+ const TEXT_BAND = 0.15;
124
+
125
+ /** Where a child is drawn: inside the SVG, over it, above it, or under it. */
126
+
127
+ /** Whether the chart is showing data or waiting for it. */
128
+
129
+ /** Whether the sides of a band are drawn as curves or as straight diagonals. */
130
+
131
+ /** One step of the process. */
132
+
133
+ /** One stage's band: where it sits along the run and how tall it is at each end. */
134
+
135
+ const FunnelChartContext = /*#__PURE__*/createContext(null);
136
+ function useChart(component) {
137
+ const context = useContext(FunnelChartContext);
138
+ if (!context) {
139
+ throw new Error(`${component} must be used within a <FunnelChart>`);
140
+ }
141
+ return context;
142
+ }
143
+
144
+ /** The selected stage and how it converted, for something drawn inside the chart. */
145
+ export function useFunnelChart() {
146
+ const {
147
+ data,
148
+ shares,
149
+ steps,
150
+ activeIndex
151
+ } = useChart('useFunnelChart');
152
+ return {
153
+ activeIndex,
154
+ activeStage: activeIndex >= 0 ? data[activeIndex] ?? null : null,
155
+ /** Its share of the first stage, 0 to 1. */
156
+ activeShare: activeIndex >= 0 ? shares[activeIndex] ?? 0 : 0,
157
+ /** Its share of the stage above it, 0 to 1. */
158
+ activeStep: activeIndex >= 0 ? steps[activeIndex] ?? 0 : 0
159
+ };
160
+ }
161
+
162
+ /** Imperative handle: re-run the entrance, for a "replay" control. */
163
+
164
+ const FunnelChartRoot = /*#__PURE__*/forwardRef(function FunnelChartRoot({
165
+ className,
166
+ data,
167
+ height = DEFAULT_HEIGHT,
168
+ stageSize,
169
+ gap = 4,
170
+ layers = DEFAULT_LAYERS,
171
+ edges = 'curved',
172
+ minWidth = 0.1,
173
+ color,
174
+ animationDuration = 700,
175
+ staggerDelay = STAGGER,
176
+ status = 'ready',
177
+ activeIndex: activeIndexProp,
178
+ onActiveIndexChange,
179
+ children,
180
+ ...props
181
+ }, ref) {
182
+ const [width, setWidth] = useState(0);
183
+ const [internalActive, setInternalActive] = useState(-1);
184
+ const reveal = useSharedValue(0);
185
+ const reducedMotion = useReducedMotion();
186
+ const direction = useDirection();
187
+ const controlled = activeIndexProp !== undefined;
188
+ const activeIndex = controlled ? activeIndexProp : internalActive;
189
+ const setActiveIndex = useMemo(() => index => {
190
+ if (!controlled) setInternalActive(index);
191
+ onActiveIndexChange?.(index);
192
+ }, [controlled, onActiveIndexChange]);
193
+ const count = data.length;
194
+ /*
195
+ * A funnel that is still loading usually has no data at all, and no stages
196
+ * to divide the width between. So an empty loading chart is given a
197
+ * plausible number of them — which is also what stops the card from jumping
198
+ * the moment the data lands.
199
+ */
200
+ const stages = count || (status === 'loading' ? SKELETON_STAGES : 0);
201
+ const spacing = Math.max(gap, 0);
202
+
203
+ /*
204
+ * The stages divide the width unless they are given a size. A run sized in
205
+ * points would stop somewhere short of the edge and leave the rest of the
206
+ * card blank, which is the one thing a chart across a card must not do.
207
+ */
208
+ const size = stageSize ?? (stages > 0 ? Math.max(0, (width - (stages - 1) * spacing) / stages) : 0);
209
+
210
+ /** The largest value in the run — see the note about broken funnels above. */
211
+ const peak = useMemo(() => data.reduce((most, stage) => Math.max(most, Math.max(0, stage.value)), 0), [data]);
212
+ const shares = useMemo(() => {
213
+ const first = Math.max(0, data[0]?.value ?? 0);
214
+ return data.map(stage => first > 0 ? Math.max(0, stage.value) / first : 0);
215
+ }, [data]);
216
+ const steps = useMemo(() => data.map((stage, index) => {
217
+ if (index === 0) return 1;
218
+ const previous = Math.max(0, data[index - 1]?.value ?? 0);
219
+ return previous > 0 ? Math.max(0, stage.value) / previous : 0;
220
+ }), [data]);
221
+
222
+ /*
223
+ * The run reads leading-edge first like the text under it, and SVG has no
224
+ * leading edge — so under a right-to-left layout the bands are laid from
225
+ * the other end, and each one's two heights swap with them.
226
+ */
227
+ const mirrored = direction === 'rtl';
228
+
229
+ /*
230
+ * The ribbon takes whatever the two text bands leave and sits centred in it,
231
+ * so the tallest stage reaches exactly to the count above and the name
232
+ * below: no strip of nothing between the shape and the words, and no shape
233
+ * creeping under them.
234
+ */
235
+ const textBand = height * TEXT_BAND;
236
+ const reach = Math.max(0, height / 2 - textBand);
237
+ const middle = height / 2;
238
+ const bands = useMemo(() => {
239
+ if (!count || width <= 0 || reach <= 0) return [];
240
+ const floor = Math.max(0, Math.min(minWidth, 1));
241
+ const extents = data.map(stage => {
242
+ const value = Math.max(0, stage.value);
243
+ if (value <= 0 || peak <= 0) return 0;
244
+ // The floor is a floor, not a rescale: a stage already above it keeps
245
+ // its true height, so the heights of the stages that matter still read
246
+ // against each other exactly.
247
+ return reach * Math.max(value / peak, floor);
248
+ });
249
+ return extents.map((extent, index) => {
250
+ const offset = index * (size + spacing);
251
+ const next = extents[index + 1] ?? extent;
252
+ return {
253
+ offset: mirrored ? width - offset - size : offset,
254
+ length: size,
255
+ head: mirrored ? next : extent,
256
+ tail: mirrored ? extent : next
257
+ };
258
+ });
259
+ }, [count, width, reach, data, peak, minWidth, size, spacing, mirrored]);
260
+ const hue = useSeriesColor(color, 1);
261
+ const colors = useMemo(() => data.map(stage => stage.color ?? hue), [data, hue]);
262
+ const strengths = useMemo(() => data.map((stage, index) => {
263
+ if (stage.color) return 1;
264
+ if (count < 2) return 1;
265
+ return 1 - index / (count - 1) * FADE;
266
+ }), [data, count]);
267
+
268
+ /*
269
+ * One clock for the whole entrance, with each stage given the slice of it
270
+ * that it grows in. A shared value per stage would be the same animation
271
+ * played `n` times and `n` more things for a replay to have to find.
272
+ */
273
+ const stagger = Math.max(0, staggerDelay);
274
+ const total = animationDuration + Math.max(count - 1, 0) * stagger;
275
+ const windows = useMemo(() => data.map((_, index) => {
276
+ const from = index * stagger;
277
+ return {
278
+ from: total > 0 ? from / total : 0,
279
+ to: total > 0 ? (from + animationDuration) / total : 1
280
+ };
281
+ }), [data, stagger, animationDuration, total]);
282
+ const playReveal = useMemo(() => () => {
283
+ if (reducedMotion) {
284
+ reveal.value = 1;
285
+ return;
286
+ }
287
+ reveal.value = 0;
288
+ // Linear, because the shaping is per stage: each one eases inside its
289
+ // own window, and easing the clock as well would ease it twice.
290
+ reveal.value = withTiming(1, {
291
+ duration: total,
292
+ easing: Easing.linear
293
+ });
294
+ }, [reducedMotion, total, reveal]);
295
+ const loading = status === 'loading';
296
+ const revealed = useRef(false);
297
+ useEffect(() => {
298
+ if (loading) {
299
+ revealed.current = false;
300
+ reveal.value = 0;
301
+ return;
302
+ }
303
+ if (revealed.current || !bands.length) return;
304
+ revealed.current = true;
305
+ playReveal();
306
+ }, [loading, bands.length, playReveal, reveal]);
307
+ useImperativeHandle(ref, () => ({
308
+ replay: playReveal
309
+ }), [playReveal]);
310
+
311
+ // The caller's own `onLayout` is not forwarded from here: it is already on
312
+ // the outer view, and the box it wants is the whole chart's rather than the
313
+ // run's — which are different heights the moment there is a header.
314
+ const onLayout = event => {
315
+ const next = Math.round(event.nativeEvent.layout.width);
316
+ if (next !== width) setWidth(next);
317
+ };
318
+ const context = useMemo(() => ({
319
+ data,
320
+ width,
321
+ height,
322
+ textBand,
323
+ middle,
324
+ bands,
325
+ shares,
326
+ steps,
327
+ colors,
328
+ strengths,
329
+ layers: Math.max(1, Math.round(layers)),
330
+ curve: edges === 'curved' ? CURVE : 0,
331
+ reveal,
332
+ windows,
333
+ status,
334
+ activeIndex,
335
+ setActiveIndex
336
+ }), [data, width, height, textBand, middle, bands, shares, steps, colors, strengths, layers, edges, reveal, windows, status, activeIndex, setActiveIndex]);
337
+ const slots = {
338
+ svg: [],
339
+ overlay: [],
340
+ header: [],
341
+ footer: []
342
+ };
343
+ Children.forEach(children, (child, index) => {
344
+ if (! /*#__PURE__*/isValidElement(child)) return;
345
+ const slot = child.type.slot ?? 'overlay';
346
+ slots[slot in slots ? slot : 'overlay'].push(/*#__PURE__*/_jsx(ChildSlot, {
347
+ children: child
348
+ }, index));
349
+ });
350
+ return /*#__PURE__*/_jsx(FunnelChartContext.Provider, {
351
+ value: context,
352
+ children: /*#__PURE__*/_jsxs(View, {
353
+ ...props,
354
+ style: props.style,
355
+ className: cn('w-full', className),
356
+ children: [slots.header, /*#__PURE__*/_jsx(View, {
357
+ onLayout: onLayout,
358
+ style: {
359
+ height
360
+ },
361
+ className: "w-full",
362
+ children: width > 0 && height > 0 ? /*#__PURE__*/_jsxs(_Fragment, {
363
+ children: [/*#__PURE__*/_jsx(Svg, {
364
+ width: width,
365
+ height: height,
366
+ children: slots.svg
367
+ }), /*#__PURE__*/_jsx(View, {
368
+ pointerEvents: "box-none",
369
+ style: {
370
+ position: 'absolute',
371
+ width,
372
+ height
373
+ },
374
+ children: slots.overlay
375
+ })]
376
+ }) : null
377
+ }), slots.footer]
378
+ })
379
+ });
380
+ });
381
+ FunnelChartRoot.displayName = 'FunnelChart';
382
+ function ChildSlot({
383
+ children
384
+ }) {
385
+ return /*#__PURE__*/_jsx(_Fragment, {
386
+ children: children
387
+ });
388
+ }
389
+ /**
390
+ * Every stage, drawn in the order the data lists them.
391
+ *
392
+ * One part rather than one per datum. A stage's near edge is the previous
393
+ * stage's far edge, so they cannot be configured apart without the ribbon
394
+ * coming apart with them — a funnel whose third stage could be given its own
395
+ * height would be a funnel drawing a shape that is not in the data.
396
+ */
397
+ function FunnelChartStages({
398
+ dimOpacity = 0.3
399
+ }) {
400
+ const {
401
+ data,
402
+ bands,
403
+ colors,
404
+ strengths,
405
+ middle,
406
+ layers,
407
+ curve,
408
+ reveal,
409
+ windows,
410
+ status,
411
+ shares,
412
+ activeIndex,
413
+ setActiveIndex
414
+ } = useChart('FunnelChart.Stages');
415
+ if (status === 'loading' || !bands.length) return null;
416
+ return /*#__PURE__*/_jsx(G, {
417
+ children: bands.map((band, index) => {
418
+ const datum = data[index];
419
+ const window = windows[index];
420
+ if (!datum || !window || band.head <= 0 && band.tail <= 0) return null;
421
+ return /*#__PURE__*/_jsx(Stage, {
422
+ band: band,
423
+ middle: middle,
424
+ curve: curve,
425
+ layers: layers,
426
+ fill: colors[index] ?? colors[0],
427
+ strength: strengths[index] ?? 1,
428
+ reveal: reveal,
429
+ window: window,
430
+ selected: activeIndex === index,
431
+ dimmed: activeIndex >= 0 && activeIndex !== index,
432
+ dimOpacity: dimOpacity,
433
+ label: datum.label,
434
+ value: datum.value,
435
+ percent: Math.round((shares[index] ?? 0) * 100),
436
+ onPress: () => setActiveIndex(activeIndex === index ? -1 : index)
437
+ }, datum.label);
438
+ })
439
+ });
440
+ }
441
+ FunnelChartStages.displayName = 'FunnelChart.Stages';
442
+ FunnelChartStages.slot = 'svg';
443
+
444
+ /**
445
+ * One stage, drawn as a stack of concentric rings.
446
+ *
447
+ * Every ring is rebuilt on the UI thread each frame it is moving. They grow out
448
+ * of the centre line rather than being scaled from nothing, because a scale
449
+ * would take the curve's control points with it and the sides would flex on the
450
+ * way in; growing the heights leaves the shape's geometry alone and only ever
451
+ * changes how far it reaches.
452
+ */
453
+ function Stage({
454
+ band,
455
+ middle,
456
+ curve,
457
+ layers,
458
+ fill,
459
+ strength,
460
+ reveal,
461
+ window,
462
+ selected,
463
+ dimmed,
464
+ dimOpacity,
465
+ label,
466
+ value,
467
+ percent,
468
+ onPress
469
+ }) {
470
+ const dim = useDerivedValue(() => withTiming(dimmed ? 1 : 0, {
471
+ duration: SELECT_DURATION
472
+ }));
473
+ const spread = useDerivedValue(() => withTiming(selected ? 1 : 0, {
474
+ duration: SELECT_DURATION
475
+ }));
476
+ return /*#__PURE__*/_jsx(G, {
477
+ onPress: onPress
478
+ // An SVG node takes a label but not a role, so the stages are reachable
479
+ // and named without being announced as buttons. `FunnelChart.Labels` and
480
+ // `FunnelChart.Legend` are the properly wired way through the same
481
+ // selection, and the larger targets.
482
+ ,
483
+ accessibilityLabel: `${label}, ${value}, ${percent} percent of the first stage`,
484
+ children: Array.from({
485
+ length: layers
486
+ }, (_, ring) => /*#__PURE__*/_jsx(Ring, {
487
+ band: band,
488
+ middle: middle,
489
+ curve: curve,
490
+ fill: fill
491
+ // The outermost ring is the tallest and the faintest, the innermost
492
+ // the tightest and the strongest, so the fill falls off towards the
493
+ // edge instead of ending at one.
494
+ ,
495
+ scale: 1 - ring / layers * RING_INSET,
496
+ opacity: strength * (RING_FAINTEST + ring / Math.max(layers - 1, 1) * (RING_STRONGEST - RING_FAINTEST))
497
+ // Selecting a stage pushes the core out further than the halo, which
498
+ // reads as the band swelling rather than as the whole thing resizing.
499
+ ,
500
+ spreadBy: ring / Math.max(layers - 1, 1) * SELECT_SPREAD,
501
+ reveal: reveal,
502
+ window: window,
503
+ spread: spread,
504
+ dim: dim,
505
+ dimOpacity: dimOpacity
506
+ }, ring))
507
+ });
508
+ }
509
+ function Ring({
510
+ band,
511
+ middle,
512
+ curve,
513
+ fill,
514
+ scale,
515
+ opacity,
516
+ spreadBy,
517
+ reveal,
518
+ window,
519
+ spread,
520
+ dim,
521
+ dimOpacity
522
+ }) {
523
+ const {
524
+ offset,
525
+ length,
526
+ head,
527
+ tail
528
+ } = band;
529
+ const {
530
+ from,
531
+ to
532
+ } = window;
533
+ const animatedProps = useAnimatedProps(() => {
534
+ const range = to - from;
535
+ const raw = range > 0 ? (reveal.value - from) / range : 1;
536
+ const progress = raw < 0 ? 0 : raw > 1 ? 1 : raw;
537
+ // Ease out cubic: fast to most of the way, then settling. Written out
538
+ // rather than called, because an easing from the animation library is not
539
+ // a worklet and this runs on the UI thread.
540
+ const eased = 1 - (1 - progress) * (1 - progress) * (1 - progress);
541
+ const reach = scale * eased * (1 + spread.value * spreadBy);
542
+ return {
543
+ d: ribbonPath(offset, length, head * reach, tail * reach, middle, curve),
544
+ fillOpacity: opacity * (1 - dim.value * (1 - dimOpacity))
545
+ };
546
+ });
547
+ return /*#__PURE__*/_jsx(AnimatedPath, {
548
+ animatedProps: animatedProps,
549
+ fill: fill
550
+ });
551
+ }
552
+ /**
553
+ * The loading state: one plain ribbon over the whole run, undivided.
554
+ *
555
+ * Deliberately undivided. Placeholder stages would be an invented drop-off, and
556
+ * a reader has no way to tell an invented one from a real one until it changes
557
+ * under them — which is worse than showing nothing, because it is showing
558
+ * something wrong.
559
+ */
560
+ function FunnelChartSkeleton({
561
+ color
562
+ }) {
563
+ const {
564
+ width,
565
+ height,
566
+ middle,
567
+ textBand,
568
+ curve,
569
+ status
570
+ } = useChart('FunnelChart.Skeleton');
571
+ const token = useCSSVariable('--color-skeleton');
572
+ const fill = color ?? (typeof token === 'string' ? token : 'rgba(128,128,128,0.2)');
573
+ if (status !== 'loading' || width <= 0 || height <= 0) return null;
574
+ const extent = Math.max(0, height / 2 - textBand);
575
+ return /*#__PURE__*/_jsx(Path, {
576
+ d: ribbonPath(0, width, extent, extent * 0.4, middle, curve),
577
+ fill: fill
578
+ });
579
+ }
580
+ FunnelChartSkeleton.displayName = 'FunnelChart.Skeleton';
581
+ FunnelChartSkeleton.slot = 'svg';
582
+
583
+ /** Which ratio a stage's pill reports. */
584
+
585
+ /**
586
+ * The readings, arranged around the ribbon: the count above the band, the name
587
+ * under it, and the conversion in a pill on the band itself.
588
+ *
589
+ * Three places rather than one line, and that is the whole point of it. Put a
590
+ * name, a count and a percentage together on one row and the row is as wide as
591
+ * all three — so at the width a phone actually has, the name is the one that
592
+ * gives way and the reader is left with "Checkout st…" against a number. Split
593
+ * around the band, each reading has the stage's full column to itself.
594
+ *
595
+ * The pill is a filled chip rather than bare text because it is the one reading
596
+ * that sits *on* the ribbon, where the fill behind it is the same token family
597
+ * as the text would be. Punched out of its own background, it reads whatever
598
+ * the band is doing underneath.
599
+ *
600
+ * The columns are pressable rather than the shape alone: a two-percent stage is
601
+ * a sliver, and the column it lives in is a target.
602
+ */
603
+ function FunnelChartLabels({
604
+ className,
605
+ formatValue,
606
+ formatShare,
607
+ share = 'top',
608
+ showValue = true,
609
+ showLabel = true
610
+ }) {
611
+ const {
612
+ data,
613
+ bands,
614
+ shares,
615
+ steps,
616
+ textBand,
617
+ status,
618
+ activeIndex,
619
+ setActiveIndex
620
+ } = useChart('FunnelChart.Labels');
621
+ if (status === 'loading' || !bands.length) return null;
622
+ const format = formatValue ?? (value => compactNumber(value));
623
+ const formatPercent = formatShare ?? (ratio => `${Math.round(ratio * 100)}%`);
624
+ return /*#__PURE__*/_jsx(_Fragment, {
625
+ children: bands.map((band, index) => {
626
+ const datum = data[index];
627
+ if (!datum) return null;
628
+ const ratio = share === 'top' ? shares[index] : steps[index];
629
+ const pill = share === 'none' || share === 'previous' && index === 0 ? null : formatPercent(ratio ?? 0, datum);
630
+ const dimmed = activeIndex >= 0 && activeIndex !== index;
631
+ const value = format(datum.value, datum);
632
+ return /*#__PURE__*/_jsxs(Pressable, {
633
+ accessibilityRole: "button",
634
+ accessibilityState: {
635
+ selected: activeIndex === index
636
+ },
637
+ accessibilityLabel: `${datum.label}, ${value}${pill ? `, ${pill}` : ''}`,
638
+ onPress: () => setActiveIndex(activeIndex === index ? -1 : index),
639
+ style: {
640
+ position: 'absolute',
641
+ left: band.offset,
642
+ width: band.length,
643
+ top: 0,
644
+ bottom: 0,
645
+ opacity: dimmed ? 0.5 : 1
646
+ },
647
+ className: cn('items-center px-1', className),
648
+ children: [/*#__PURE__*/_jsx(View, {
649
+ style: {
650
+ height: textBand
651
+ },
652
+ className: "justify-end pb-1",
653
+ children: showValue ? /*#__PURE__*/_jsx(Text, {
654
+ size: "sm",
655
+ weight: "semibold",
656
+ numberOfLines: 1,
657
+ children: value
658
+ }) : null
659
+ }), /*#__PURE__*/_jsx(View, {
660
+ className: "flex-1 justify-center",
661
+ children: pill ? /*#__PURE__*/_jsx(View, {
662
+ className: "rounded-full border border-border bg-background px-2 py-0.5",
663
+ children: /*#__PURE__*/_jsx(Text, {
664
+ size: "xs",
665
+ weight: "bold",
666
+ numberOfLines: 1,
667
+ children: pill
668
+ })
669
+ }) : null
670
+ }), /*#__PURE__*/_jsx(View, {
671
+ style: {
672
+ height: textBand
673
+ },
674
+ className: "justify-start pt-1",
675
+ children: showLabel ? /*#__PURE__*/_jsx(Text, {
676
+ size: "xs",
677
+ muted: true,
678
+ weight: "medium",
679
+ numberOfLines: 1,
680
+ children: datum.label
681
+ }) : null
682
+ })]
683
+ }, datum.label);
684
+ })
685
+ });
686
+ }
687
+ FunnelChartLabels.displayName = 'FunnelChart.Labels';
688
+ FunnelChartLabels.slot = 'overlay';
689
+
690
+ /** How the key under the run is arranged. */
691
+
692
+ /**
693
+ * A swatch, a name and a reading per stage, under the run. Pressable in the
694
+ * same way the stages are.
695
+ *
696
+ * For a funnel drawn without `Labels` — a compact one on a dashboard card,
697
+ * where the run is a shape and the reading is underneath it.
698
+ *
699
+ * A row each, by default. The stages of a funnel are a sequence, and a wrapped
700
+ * centred line loses that: the reader gets a ragged block of names in which the
701
+ * order is only implied by the order they happen to be read in, and a long
702
+ * stage name breaks it across lines that no longer line up with anything. Down
703
+ * a column the order is the order, and the numbers stack into a column of their
704
+ * own that can be compared at a glance.
705
+ */
706
+ function FunnelChartLegend({
707
+ className,
708
+ layout = 'list',
709
+ showValue = true,
710
+ formatValue,
711
+ ...props
712
+ }) {
713
+ const {
714
+ data,
715
+ shares,
716
+ colors,
717
+ strengths,
718
+ activeIndex,
719
+ setActiveIndex
720
+ } = useChart('FunnelChart.Legend');
721
+ if (!data.length) return null;
722
+ const list = layout === 'list';
723
+ const format = formatValue ?? (value => compactNumber(value));
724
+ return /*#__PURE__*/_jsx(View, {
725
+ ...props,
726
+ className: cn('w-full pt-3', list ? 'gap-1.5' : 'flex-row flex-wrap items-center justify-center gap-x-3 gap-y-1.5', className),
727
+ children: data.map((stage, index) => {
728
+ const percent = Math.round((shares[index] ?? 0) * 100);
729
+ const dimmed = activeIndex >= 0 && activeIndex !== index;
730
+ return /*#__PURE__*/_jsxs(Pressable, {
731
+ accessibilityRole: "button",
732
+ accessibilityState: {
733
+ selected: activeIndex === index
734
+ },
735
+ accessibilityLabel: `${stage.label}, ${percent} percent of the first stage`,
736
+ onPress: () => setActiveIndex(activeIndex === index ? -1 : index),
737
+ style: {
738
+ opacity: dimmed ? 0.4 : 1
739
+ },
740
+ className: cn('flex-row items-center gap-1.5', list ? 'w-full' : 'max-w-full'),
741
+ children: [/*#__PURE__*/_jsx(View, {
742
+ style: {
743
+ width: 8,
744
+ height: 8,
745
+ borderRadius: 4,
746
+ backgroundColor: colors[index],
747
+ opacity: strengths[index] ?? 1
748
+ }
749
+ }), /*#__PURE__*/_jsx(Text, {
750
+ size: "xs",
751
+ muted: true,
752
+ numberOfLines: 1,
753
+ className: list ? 'flex-1' : 'shrink',
754
+ children: stage.label
755
+ }), showValue ? /*#__PURE__*/_jsxs(_Fragment, {
756
+ children: [list ? /*#__PURE__*/_jsx(Text, {
757
+ size: "xs",
758
+ weight: "medium",
759
+ numberOfLines: 1,
760
+ children: format(stage.value, stage)
761
+ }) : null, /*#__PURE__*/_jsxs(Text, {
762
+ size: "xs",
763
+ muted: list,
764
+ weight: list ? 'normal' : 'medium',
765
+ numberOfLines: 1,
766
+ className: list ? 'w-10 text-right' : undefined,
767
+ children: [percent, "%"]
768
+ })]
769
+ }) : null]
770
+ }, stage.label);
771
+ })
772
+ });
773
+ }
774
+ FunnelChartLegend.displayName = 'FunnelChart.Legend';
775
+ FunnelChartLegend.slot = 'footer';
776
+ /**
777
+ * The strip above the run: what the funnel is of and what it reads.
778
+ *
779
+ * It belongs to the chart rather than to the card around it because it is about
780
+ * the *stages* — the number changes as one is selected. The card's header is a
781
+ * caption on the tray the chart sits in; this is the chart introducing itself.
782
+ *
783
+ * The value is not derived even though there is a first stage to derive it
784
+ * from, because the formatting is not the chart's to guess: 41800 is a count,
785
+ * a currency or a rate depending on what was counted.
786
+ */
787
+ function FunnelChartHeader({
788
+ className,
789
+ title,
790
+ value,
791
+ caption,
792
+ children,
793
+ ...props
794
+ }) {
795
+ return /*#__PURE__*/_jsxs(View, {
796
+ ...props,
797
+ className: cn('flex-row items-start justify-between gap-3 pb-3', className),
798
+ children: [/*#__PURE__*/_jsxs(View, {
799
+ className: "flex-1 gap-0.5",
800
+ children: [title ? /*#__PURE__*/_jsx(Text, {
801
+ size: "xs",
802
+ muted: true,
803
+ children: title
804
+ }) : null, value ? /*#__PURE__*/_jsx(Text, {
805
+ size: "xl",
806
+ weight: "bold",
807
+ children: value
808
+ }) : null, caption ? /*#__PURE__*/_jsx(Text, {
809
+ size: "xs",
810
+ muted: true,
811
+ children: caption
812
+ }) : null]
813
+ }), children ? /*#__PURE__*/_jsx(View, {
814
+ className: "max-w-[55%] shrink pt-1",
815
+ children: children
816
+ }) : null]
817
+ });
818
+ }
819
+ FunnelChartHeader.displayName = 'FunnelChart.Header';
820
+ FunnelChartHeader.slot = 'header';
821
+ export const FunnelChart = Object.assign(FunnelChartRoot, {
822
+ Header: FunnelChartHeader,
823
+ Stages: FunnelChartStages,
824
+ Labels: FunnelChartLabels,
825
+ Legend: FunnelChartLegend,
826
+ Skeleton: FunnelChartSkeleton
827
+ });
828
+ //# sourceMappingURL=index.js.map