@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 +3 -3
- package/src/components/text-animation/AGENTS.md +6 -1
- package/src/components/text-animation/index.ts +4 -0
- package/src/components/text-animation/text-animation-counting.tsx +11 -1
- package/src/components/text-animation/text-animation-rolling.tsx +17 -5
- package/src/components/text-animation/text-animation.variants.test.ts +62 -0
- package/src/components/text-animation/text-animation.variants.ts +51 -9
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@delacour/react-native-ui",
|
|
3
|
-
"version": "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": "
|
|
186
|
-
"@delacour/react-native-charts": "
|
|
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
|
|
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>(
|
|
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
|
|
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
|
|
167
|
-
|
|
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
|
-
*
|
|
517
|
+
* {@link formatNumber}, unjoined. A value with no number is the dash alone, affixes dropped.
|
|
515
518
|
*
|
|
516
|
-
*
|
|
517
|
-
*
|
|
518
|
-
*
|
|
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
|
|
523
|
+
export function formatNumberParts(value: number, format: TextAnimationNumberFormat): NumberParts {
|
|
523
524
|
"worklet";
|
|
524
|
-
if (!Number.isFinite(value)) return "
|
|
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
|
|
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
|
+
}
|