panelui-native 0.57.0 → 0.59.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.
@@ -5,6 +5,7 @@ import Animated, {
5
5
  cancelAnimation,
6
6
  interpolate,
7
7
  useAnimatedStyle,
8
+ useReducedMotion,
8
9
  useSharedValue,
9
10
  withRepeat,
10
11
  withSpring,
@@ -18,6 +19,12 @@ import { cn } from '../../utils/cn';
18
19
  const SPRING = { damping: 20, stiffness: 180, mass: 0.6 } as const;
19
20
  /** Fraction of the track covered by the sliding bar in indeterminate mode. */
20
21
  const INDETERMINATE_WIDTH = 0.4;
22
+ /** Milliseconds for the bar to cross the track once. */
23
+ const SWEEP_DURATION = 1100;
24
+ /** Milliseconds for one half of the fade that stands in for the sweep. */
25
+ const PULSE_DURATION = 900;
26
+ /** How far down the fade goes. Faint enough to read as a pulse, not a flicker. */
27
+ const PULSE_FLOOR = 0.35;
21
28
 
22
29
  const progressVariants = tv({
23
30
  slots: {
@@ -67,7 +74,11 @@ export interface ProgressProps
67
74
  * to `100`, so a bare percentage keeps working with neither bound set.
68
75
  */
69
76
  maxValue?: number;
70
- /** Show a looping animation for unknown-duration work. */
77
+ /**
78
+ * Show a looping animation for unknown-duration work. Under the platform's
79
+ * reduce-motion setting the bar fills the track and pulses instead of
80
+ * travelling across it, so it still reads as running.
81
+ */
71
82
  indeterminate?: boolean;
72
83
  /** Extra classes for the moving indicator. */
73
84
  indicatorClassName?: string;
@@ -156,6 +167,10 @@ function formatValue(
156
167
  * the work is actually counted in: bytes uploaded, seats filled, points
157
168
  * scored. Nothing has to be converted to a percent on the way in, and the
158
169
  * screen reader is told the real range rather than a derived one.
170
+ *
171
+ * Reduce motion is honoured in both modes: the fill lands on its value rather
172
+ * than springing to it, and the indeterminate bar pulses in place rather than
173
+ * crossing the track.
159
174
  */
160
175
  export const Progress = forwardRef<View, ProgressProps>(
161
176
  (
@@ -185,43 +200,78 @@ export const Progress = forwardRef<View, ProgressProps>(
185
200
  const target = fractionOf(value, minValue, maxValue);
186
201
  const progress = useSharedValue(target);
187
202
  const slide = useSharedValue(0);
203
+ const reducedMotion = useReducedMotion();
188
204
 
189
205
  /**
190
- * The loop is tied to `indeterminate` alone. Restarting it whenever the
191
- * value moved used to leave `withRepeat` cycling from wherever the bar had
192
- * got to, so a bar that kept receiving values while looping ended up
193
- * sweeping a shrinking sliver of the track instead of crossing it.
206
+ * The loop is tied to `indeterminate` and to how much motion is wanted.
207
+ * Restarting it whenever the value moved used to leave `withRepeat`
208
+ * cycling from wherever the bar had got to, so a bar that kept receiving
209
+ * values while looping ended up sweeping a shrinking sliver of the track
210
+ * instead of crossing it.
211
+ *
212
+ * Under reduce motion the same loop drives a fade rather than a journey
213
+ * across the track. Freezing it outright is the other option and it is the
214
+ * wrong one: a bar that stops moving reads as a bar that has hung, which is
215
+ * the one thing an indeterminate bar exists to rule out. Reduce motion asks
216
+ * for less travel, not for less news — so the travel goes and the pulse
217
+ * stays, running slower than the sweep it replaces.
194
218
  */
195
219
  useEffect(() => {
196
220
  if (!indeterminate) return undefined;
197
221
  slide.value = 0;
198
222
  slide.value = withRepeat(
199
- withTiming(1, { duration: 1100, easing: Easing.inOut(Easing.ease) }),
223
+ withTiming(1, {
224
+ duration: reducedMotion ? PULSE_DURATION : SWEEP_DURATION,
225
+ easing: Easing.inOut(Easing.ease),
226
+ }),
200
227
  -1,
201
- false
228
+ // The sweep restarts at the left edge; the fade turns around, because a
229
+ // pulse that snapped back to full brightness would be the flicker the
230
+ // setting is there to avoid.
231
+ reducedMotion
202
232
  );
203
233
  return () => cancelAnimation(slide);
204
- }, [indeterminate, slide]);
234
+ }, [indeterminate, reducedMotion, slide]);
205
235
 
206
236
  // The fill follows the value, and picks up from wherever the loop left it
207
- // when a bar stops being indeterminate mid-flight.
237
+ // when a bar stops being indeterminate mid-flight. Reduce motion lands on
238
+ // the value instead of springing to it — the number is the information, and
239
+ // the overshoot is decoration.
208
240
  useEffect(() => {
209
241
  if (indeterminate) return;
210
- progress.value = withSpring(target, SPRING);
211
- }, [indeterminate, target, progress]);
242
+ progress.value = reducedMotion ? target : withSpring(target, SPRING);
243
+ }, [indeterminate, target, progress, reducedMotion]);
212
244
 
213
245
  const onLayout = (event: LayoutChangeEvent) => {
214
246
  trackWidth.value = event.nativeEvent.layout.width;
215
247
  };
216
248
 
249
+ /*
250
+ * Both styles set every property either of them sets. The two are swapped
251
+ * on the same view when a bar stops being indeterminate, and an animated
252
+ * property that one style drops is not reset by the other — it is simply
253
+ * left at whatever it was last given, which would strand a half-faded bar
254
+ * at that opacity for the rest of its life.
255
+ */
217
256
  const determinateStyle = useAnimatedStyle(() => ({
218
257
  width: trackWidth.value * progress.value,
258
+ opacity: 1,
259
+ transform: [{ translateX: 0 }],
219
260
  }));
220
261
 
221
262
  const indeterminateStyle = useAnimatedStyle(() => {
263
+ if (reducedMotion) {
264
+ // Nothing travels: the whole track carries the bar and the bar breathes.
265
+ return {
266
+ width: trackWidth.value,
267
+ opacity: interpolate(slide.value, [0, 1], [1, PULSE_FLOOR]),
268
+ transform: [{ translateX: 0 }],
269
+ };
270
+ }
222
271
  const barWidth = trackWidth.value * INDETERMINATE_WIDTH;
223
272
  return {
224
273
  width: barWidth,
274
+ opacity: 1,
225
275
  transform: [
226
276
  {
227
277
  // The loop travels the way the text does. Yoga mirrors the track
@@ -253,6 +303,9 @@ export const Progress = forwardRef<View, ProgressProps>(
253
303
  ref={ref}
254
304
  accessibilityRole="progressbar"
255
305
  accessibilityLabel={label}
306
+ // Indeterminate has no value to announce, so `busy` is the only thing
307
+ // separating "working, length unknown" from "empty and going nowhere".
308
+ accessibilityState={indeterminate ? { busy: true } : undefined}
256
309
  accessibilityValue={
257
310
  indeterminate
258
311
  ? undefined
package/src/index.ts CHANGED
@@ -216,6 +216,22 @@ export {
216
216
  type FlowRect,
217
217
  type FlowPoint,
218
218
  } from './components/flow';
219
+ export {
220
+ FunnelChart,
221
+ useFunnelChart,
222
+ type FunnelChartProps,
223
+ type FunnelChartHandle,
224
+ type FunnelChartHeaderProps,
225
+ type FunnelChartStagesProps,
226
+ type FunnelChartLabelsProps,
227
+ type FunnelChartLegendProps,
228
+ type FunnelChartSkeletonProps,
229
+ type FunnelChartStatus,
230
+ type FunnelDatum,
231
+ type FunnelEdges,
232
+ type FunnelLegendLayout,
233
+ type FunnelShare,
234
+ } from './components/funnel-chart';
219
235
  export {
220
236
  Frame,
221
237
  type FrameProps,
@@ -751,3 +751,55 @@ export function columnValues(
751
751
  return typeof value === 'number' && !Number.isNaN(value) ? value : null;
752
752
  });
753
753
  }
754
+
755
+ /**
756
+ * One length of a funnel: a band symmetrical about a centre line, `head` wide
757
+ * at one end and `tail` wide at the other.
758
+ *
759
+ * Given as two half-extents about a middle rather than as four corners, because
760
+ * that is the shape of the data — a stage knows what it is worth and what the
761
+ * next one is worth, and the taper between them is the drop.
762
+ *
763
+ * `curve` is how far along the band the control points reach, as a fraction of
764
+ * its length. Past `0.5` the two reach beyond each other, which is what turns
765
+ * the join from a diagonal into the S the eye reads as a single continuous
766
+ * funnel rather than a stack of separate trapezoids; `0` gives the straight
767
+ * diagonal. The sides are the only curved part — the ends stay square, so a
768
+ * band's end lines up exactly with the next band's start.
769
+ *
770
+ * The run goes across, so `offset` and `length` are horizontal and the band is
771
+ * `head` and `tail` tall about `middle`.
772
+ */
773
+ export function ribbonPath(
774
+ offset: number,
775
+ length: number,
776
+ head: number,
777
+ tail: number,
778
+ middle: number,
779
+ curve: number
780
+ ): string {
781
+ 'worklet';
782
+ if (length <= 0 || (head <= 0 && tail <= 0)) return '';
783
+
784
+ const start = offset;
785
+ const end = offset + length;
786
+ const topStart = middle - head;
787
+ const topEnd = middle - tail;
788
+ const bottomStart = middle + head;
789
+ const bottomEnd = middle + tail;
790
+
791
+ if (!(curve > 0)) {
792
+ return `M${start},${topStart}L${end},${topEnd}L${end},${bottomEnd}L${start},${bottomStart}Z`;
793
+ }
794
+
795
+ const near = start + length * curve;
796
+ const far = end - length * curve;
797
+
798
+ return (
799
+ `M${start},${topStart}` +
800
+ `C${near},${topStart},${far},${topEnd},${end},${topEnd}` +
801
+ `L${end},${bottomEnd}` +
802
+ `C${far},${bottomEnd},${near},${bottomStart},${start},${bottomStart}` +
803
+ `Z`
804
+ );
805
+ }