@delacour/react-native-ui 0.5.0 → 0.6.0-alpha.20261030084636

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@delacour/react-native-ui",
3
- "version": "0.5.0",
3
+ "version": "0.6.0-alpha.20261030084636",
4
4
  "description": "React Native UI components — Uniwind, Reanimated, Gesture Handler",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -182,8 +182,8 @@
182
182
  "expo-blur": ">=15",
183
183
  "@maplibre/maplibre-react-native": "^11.5.0",
184
184
  "@central-icons-react-native/round-outlined-radius-1-stroke-1.5": "^1.1",
185
- "@delacour/react-native-bottom-sheet": ">=0.3.0",
186
- "@delacour/react-native-charts": ">=0.2.0",
185
+ "@delacour/react-native-bottom-sheet": "0.6.0-alpha.20261030084636",
186
+ "@delacour/react-native-charts": "0.6.0-alpha.20261030084636",
187
187
  "@legendapp/list": ">=3.3",
188
188
  "react-native-skia": ">=3.7.3",
189
189
  "expo-linear-gradient": ">=15",
@@ -110,7 +110,12 @@ scope — it is an input, not a text effect.
110
110
  `0`, which is what a position of 9.5 shows half of.
111
111
  - **Wheels are keyed by their place from the right.** 999 → 1000 keeps three
112
112
  wheels and mounts a fourth, which rolls up from 0. Least-significant first,
113
- `stagger` ms apart. Separators, sign, prefix and suffix are static cells.
113
+ `stagger` ms apart. Separators and the sign are static cells.
114
+ - **An affix is one static cell, digits and all.** `resolveNumberCells` splits
115
+ the reading through `formatNumberParts`, so only the body is cut into wheels —
116
+ `suffix="/100"` and `prefix="Q3 "` never roll. `prefix` / `suffix` are props on
117
+ both numeric parts as well as `format` fields; `resolveNumberFormat` folds
118
+ them in, a prop winning.
114
119
  - **The wheel height is measured, never fixed.** One hidden digit reports the line
115
120
  height every wheel offsets by, so the odometer follows font scaling. Until it
116
121
  has, the strips are hidden.
@@ -3,10 +3,14 @@ export type { TextAnimationBase } from "./text-animation.types";
3
3
  export {
4
4
  buildTypingTimeline,
5
5
  formatNumber,
6
+ formatNumberParts,
7
+ type NumberParts,
6
8
  resolveCaretClass,
7
9
  resolveCaretOpacity,
8
10
  resolveEffectFrame,
9
11
  resolveNextPhrase,
12
+ resolveNumberCells,
13
+ resolveNumberFormat,
10
14
  resolveRollTarget,
11
15
  resolveStaggerDelay,
12
16
  resolveTotalDuration,
@@ -13,6 +13,7 @@ import { cn } from "../../lib/cn";
13
13
  import type { TextAnimationBase } from "./text-animation.types";
14
14
  import {
15
15
  formatNumber,
16
+ resolveNumberFormat,
16
17
  TEXT_ANIMATION_COUNT_DURATION_MS,
17
18
  type TextAnimationNumberFormat,
18
19
  textAnimationVariants,
@@ -28,6 +29,10 @@ export type TextAnimationCountingProps = TextAnimationBase & {
28
29
  from?: number;
29
30
  /** Grouping, decimals, prefix and suffix. */
30
31
  format?: TextAnimationNumberFormat;
32
+ /** Text before the number, after any sign — `−$5`. Never rolls or tweens. Wins over `format.prefix`. */
33
+ prefix?: string;
34
+ /** Text after the number — `%`, ` kg`, `/100`. Never rolls or tweens. Wins over `format.suffix`. */
35
+ suffix?: string;
31
36
  /** The tween, ms. Default 900. */
32
37
  durationMs?: number;
33
38
  /** Default `out`. */
@@ -82,6 +87,8 @@ export function TextAnimationCounting({
82
87
  value,
83
88
  from = 0,
84
89
  format,
90
+ prefix,
91
+ suffix,
85
92
  durationMs = TEXT_ANIMATION_COUNT_DURATION_MS,
86
93
  easing = "out",
87
94
  animateOnMount = true,
@@ -104,7 +111,10 @@ export function TextAnimationCounting({
104
111
  "tabular-nums"
105
112
  );
106
113
  // biome-ignore lint/correctness/useExhaustiveDependencies: the format's fields are its identity
107
- const stableFormat = useMemo<TextAnimationNumberFormat>(() => format ?? {}, [JSON.stringify(format ?? {})]);
114
+ const stableFormat = useMemo<TextAnimationNumberFormat>(
115
+ () => resolveNumberFormat(format, prefix, suffix),
116
+ [JSON.stringify(format ?? {}), prefix, suffix]
117
+ );
108
118
  const start = animateOnMount && !isCalm ? from : value;
109
119
  const shown = useSharedValue(start);
110
120
  const target = formatNumber(value, stableFormat);
@@ -14,10 +14,11 @@ import type { TextAnimationBase } from "./text-animation.types";
14
14
  import {
15
15
  formatNumber,
16
16
  type RollDirection,
17
+ resolveNumberCells,
18
+ resolveNumberFormat,
17
19
  resolveRollDirection,
18
20
  resolveRollTarget,
19
21
  resolveWheelOffset,
20
- splitNumberCells,
21
22
  TEXT_ANIMATION_ROLL_DURATION_MS,
22
23
  TEXT_ANIMATION_ROLL_STAGGER,
23
24
  type TextAnimationNumberFormat,
@@ -30,6 +31,10 @@ export type TextAnimationRollingProps = TextAnimationBase & {
30
31
  value: number;
31
32
  /** Grouping, decimals, prefix and suffix. Separators render static; only digits roll. */
32
33
  format?: TextAnimationNumberFormat;
34
+ /** Text before the number, after any sign — `−$5`. Never rolls or tweens. Wins over `format.prefix`. */
35
+ prefix?: string;
36
+ /** Text after the number — `%`, ` kg`, `/100`. Never rolls or tweens. Wins over `format.suffix`. */
37
+ suffix?: string;
33
38
  /** One wheel's roll, ms. Default 600. */
34
39
  durationMs?: number;
35
40
  /** Delay between wheels, least significant first, ms. Default 40. */
@@ -134,14 +139,17 @@ TextAnimationWheel.displayName = "DelacourUI.TextAnimation.Wheel";
134
139
  * An odometer: each digit is a wheel that rolls to its new digit.
135
140
  *
136
141
  * Wheels are keyed by their place from the right, so 999 → 1000 keeps the
137
- * three wheels it had and mounts a fourth, which rolls up from 0. Separators,
138
- * the sign, the prefix and the suffix are static cells. Every glyph is set in
142
+ * three wheels it had and mounts a fourth, which rolls up from 0. Separators
143
+ * and the sign are static cells; the prefix and the suffix are one static cell
144
+ * each, so a digit inside one never rolls. Every glyph is set in
139
145
  * tabular figures, so no digit is wider than another and nothing jitters
140
146
  * sideways mid-roll. Under calm motion each wheel lands at once.
141
147
  */
142
148
  export function TextAnimationRolling({
143
149
  value,
144
150
  format,
151
+ prefix,
152
+ suffix,
145
153
  durationMs = TEXT_ANIMATION_ROLL_DURATION_MS,
146
154
  stagger = TEXT_ANIMATION_ROLL_STAGGER,
147
155
  variant,
@@ -163,8 +171,12 @@ export function TextAnimationRolling({
163
171
  "tabular-nums"
164
172
  );
165
173
  // biome-ignore lint/correctness/useExhaustiveDependencies: the format's fields are its identity
166
- const formatted = useMemo(() => formatNumber(value, format ?? {}), [value, JSON.stringify(format ?? {})]);
167
- const cells = useMemo(() => splitNumberCells(formatted), [formatted]);
174
+ const resolvedFormat = useMemo(
175
+ () => resolveNumberFormat(format, prefix, suffix),
176
+ [JSON.stringify(format ?? {}), prefix, suffix]
177
+ );
178
+ const formatted = useMemo(() => formatNumber(value, resolvedFormat), [value, resolvedFormat]);
179
+ const cells = useMemo(() => resolveNumberCells(value, resolvedFormat), [value, resolvedFormat]);
168
180
 
169
181
  const [direction, setDirection] = useState<{ value: number; direction: RollDirection }>({
170
182
  value,
@@ -2,11 +2,14 @@ import { describe, expect, test } from "bun:test";
2
2
  import {
3
3
  buildTypingTimeline,
4
4
  formatNumber,
5
+ formatNumberParts,
5
6
  hashText,
6
7
  resolveCaretClass,
7
8
  resolveCaretOpacity,
8
9
  resolveEffectFrame,
9
10
  resolveNextPhrase,
11
+ resolveNumberCells,
12
+ resolveNumberFormat,
10
13
  resolveRollDirection,
11
14
  resolveRollTarget,
12
15
  resolveRowJustify,
@@ -478,6 +481,65 @@ describe("splitNumberCells", () => {
478
481
  });
479
482
  });
480
483
 
484
+ describe("formatNumberParts", () => {
485
+ test("splits a reading into sign, prefix, body and suffix", () => {
486
+ expect(formatNumberParts(-1234.5, { decimals: 1, prefix: "$", suffix: " NZD" })).toEqual({
487
+ sign: "−",
488
+ prefix: "$",
489
+ body: "1,234.5",
490
+ suffix: " NZD",
491
+ });
492
+ });
493
+
494
+ test("joined, the parts are formatNumber", () => {
495
+ const format = { decimals: 2, prefix: "~", suffix: "%" };
496
+ const parts = formatNumberParts(-42.125, format);
497
+ expect(`${parts.sign}${parts.prefix}${parts.body}${parts.suffix}`).toBe(formatNumber(-42.125, format));
498
+ });
499
+
500
+ test("a value with no number keeps its affixes off the dash", () => {
501
+ expect(formatNumberParts(Number.NaN, { prefix: "$" })).toEqual({ sign: "", prefix: "", body: "—", suffix: "" });
502
+ });
503
+ });
504
+
505
+ describe("resolveNumberFormat", () => {
506
+ test("a prop affix wins over the format's", () => {
507
+ expect(resolveNumberFormat({ prefix: "$", suffix: "%", decimals: 1 }, "€", undefined)).toEqual({
508
+ prefix: "€",
509
+ suffix: "%",
510
+ decimals: 1,
511
+ });
512
+ });
513
+
514
+ test("no format and no affix is an empty format", () => {
515
+ expect(resolveNumberFormat(undefined, undefined, undefined)).toEqual({});
516
+ });
517
+ });
518
+
519
+ describe("resolveNumberCells", () => {
520
+ // "/100" rolled as three wheels before: an affix is text, not a reading.
521
+ test("an affix is one static cell, digits and all", () => {
522
+ expect(resolveNumberCells(72, { prefix: "Q3 ", suffix: "/100" })).toEqual([
523
+ { kind: "static", text: "Q3 " },
524
+ { kind: "digit", digit: 7 },
525
+ { kind: "digit", digit: 2 },
526
+ { kind: "static", text: "/100" },
527
+ ]);
528
+ });
529
+
530
+ test("the sign sits ahead of the prefix", () => {
531
+ expect(resolveNumberCells(-5, { prefix: "$" })).toEqual([
532
+ { kind: "static", text: "−" },
533
+ { kind: "static", text: "$" },
534
+ { kind: "digit", digit: 5 },
535
+ ]);
536
+ });
537
+
538
+ test("no affix is the body's cells alone", () => {
539
+ expect(resolveNumberCells(1248, {})).toEqual(splitNumberCells("1,248"));
540
+ });
541
+ });
542
+
481
543
  describe("resolveCaretClass", () => {
482
544
  test("a bar is a thin column, a block a translucent fill, both on the text's own colour", () => {
483
545
  expect(resolveCaretClass("bar")).toContain("bg-foreground");
@@ -77,6 +77,9 @@ export type TypingSample = { phraseIndex: number; visibleCount: number; phase: T
77
77
 
78
78
  export type EffectFrame = { opacity: number; translateX: number; translateY: number; scale: number };
79
79
 
80
+ /** A formatted reading in its four pieces, so a part can treat the affixes as text rather than digits. */
81
+ export type NumberParts = { sign: string; prefix: string; body: string; suffix: string };
82
+
80
83
  export type NumberCell = { kind: "digit"; digit: number } | { kind: "static"; text: string };
81
84
 
82
85
  export type RollDirection = "up" | "down";
@@ -511,17 +514,15 @@ function groupDigits(whole: string, separator: string): string {
511
514
  }
512
515
 
513
516
  /**
514
- * Writes a number with grouping, fixed decimals, a prefix and a suffix.
517
+ * {@link formatNumber}, unjoined. A value with no number is the dash alone, affixes dropped.
515
518
  *
516
- * A worklet: `Counting` calls it on the UI thread every frame, where Hermes
517
- * has no `Intl`, so grouping is done by hand. Rounding is `toFixed`'s, half
518
- * cases included. A negative takes a true minus sign ahead of the prefix
519
- * (`−$5`); one that rounds to zero takes none. `NaN` and the infinities are
520
- * written {@link TEXT_ANIMATION_NO_VALUE}.
519
+ * Declared above `formatNumber` on purpose: the worklets plugin turns a
520
+ * worklet into a value that captures what it calls when it is defined, so a
521
+ * worklet declared below its caller is captured as `undefined`.
521
522
  */
522
- export function formatNumber(value: number, format: TextAnimationNumberFormat): string {
523
+ export function formatNumberParts(value: number, format: TextAnimationNumberFormat): NumberParts {
523
524
  "worklet";
524
- if (!Number.isFinite(value)) return "\u2014";
525
+ if (!Number.isFinite(value)) return { sign: "", prefix: "", body: TEXT_ANIMATION_NO_VALUE, suffix: "" };
525
526
  const decimals = Math.max(0, Math.min(20, Math.floor(format.decimals ?? 0)));
526
527
  const groupSeparator = format.groupSeparator ?? ",";
527
528
  const decimalSeparator = format.decimalSeparator ?? ".";
@@ -537,7 +538,34 @@ export function formatNumber(value: number, format: TextAnimationNumberFormat):
537
538
  // U+2212, a true minus — the width of a plus and of a tabular digit — rather than a hyphen.
538
539
  const sign = value < 0 && !isZero ? "\u2212" : "";
539
540
  const body = fraction === "" ? grouped : `${grouped}${decimalSeparator}${fraction}`;
540
- return `${sign}${format.prefix ?? ""}${body}${format.suffix ?? ""}`;
541
+ return { sign, prefix: format.prefix ?? "", body, suffix: format.suffix ?? "" };
542
+ }
543
+
544
+ /**
545
+ * Writes a number with grouping, fixed decimals, a prefix and a suffix.
546
+ *
547
+ * A worklet: `Counting` calls it on the UI thread every frame, where Hermes
548
+ * has no `Intl`, so grouping is done by hand. Rounding is `toFixed`'s, half
549
+ * cases included. A negative takes a true minus sign ahead of the prefix
550
+ * (`−$5`); one that rounds to zero takes none. `NaN` and the infinities are
551
+ * written {@link TEXT_ANIMATION_NO_VALUE}.
552
+ */
553
+ export function formatNumber(value: number, format: TextAnimationNumberFormat): string {
554
+ "worklet";
555
+ const parts = formatNumberParts(value, format);
556
+ return `${parts.sign}${parts.prefix}${parts.body}${parts.suffix}`;
557
+ }
558
+
559
+ /** The `prefix` / `suffix` props folded into `format` — a prop wins over the format's own. */
560
+ export function resolveNumberFormat(
561
+ format: TextAnimationNumberFormat | undefined,
562
+ prefix: string | undefined,
563
+ suffix: string | undefined
564
+ ): TextAnimationNumberFormat {
565
+ const resolved: TextAnimationNumberFormat = { ...format };
566
+ if (prefix !== undefined) resolved.prefix = prefix;
567
+ if (suffix !== undefined) resolved.suffix = suffix;
568
+ return resolved;
541
569
  }
542
570
 
543
571
  /**
@@ -577,3 +605,17 @@ export function splitNumberCells(formatted: string): readonly NumberCell[] {
577
605
  }
578
606
  return cells;
579
607
  }
608
+
609
+ /**
610
+ * A reading as `Rolling`'s cells. The sign, the prefix and the suffix are one
611
+ * static cell each, so a digit in an affix — `/100`, `Q3 ` — never rolls.
612
+ */
613
+ export function resolveNumberCells(value: number, format: TextAnimationNumberFormat): readonly NumberCell[] {
614
+ const parts = formatNumberParts(value, format);
615
+ const cells: NumberCell[] = [];
616
+ if (parts.sign !== "") cells.push({ kind: "static", text: parts.sign });
617
+ if (parts.prefix !== "") cells.push({ kind: "static", text: parts.prefix });
618
+ cells.push(...splitNumberCells(parts.body));
619
+ if (parts.suffix !== "") cells.push({ kind: "static", text: parts.suffix });
620
+ return cells;
621
+ }