@delacour/react-native-ui 0.5.0 → 0.6.0-alpha.20261030084745
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 +5 -5
- package/src/cn.parity.test.ts +2 -2
- package/src/components/kpi/kpi-icon.tsx +1 -1
- package/src/components/kpi/kpi.variants.test.ts +2 -2
- package/src/components/kpi/kpi.variants.ts +6 -6
- package/src/components/leaderboard/leaderboard-podium-place.tsx +2 -2
- package/src/components/leaderboard/leaderboard.variants.test.ts +7 -7
- package/src/components/leaderboard/leaderboard.variants.ts +3 -3
- package/src/components/planner/planner-day.tsx +2 -2
- package/src/components/planner/planner-legend.tsx +1 -1
- package/src/components/planner/planner.variants.test.ts +3 -3
- package/src/components/planner/planner.variants.ts +5 -5
- 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/src/lib/tv.ts +4 -1
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.20261030084745",
|
|
4
4
|
"description": "React Native UI components — Uniwind, Reanimated, Gesture Handler",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -175,15 +175,14 @@
|
|
|
175
175
|
},
|
|
176
176
|
"dependencies": {
|
|
177
177
|
"cn": "^0.5.0",
|
|
178
|
-
"tailwind-
|
|
179
|
-
"tailwind-variants": "^3.3.1"
|
|
178
|
+
"tailwind-variants": "^4.0.0"
|
|
180
179
|
},
|
|
181
180
|
"peerDependencies": {
|
|
182
181
|
"expo-blur": ">=15",
|
|
183
182
|
"@maplibre/maplibre-react-native": "^11.5.0",
|
|
184
183
|
"@central-icons-react-native/round-outlined-radius-1-stroke-1.5": "^1.1",
|
|
185
|
-
"@delacour/react-native-bottom-sheet": "
|
|
186
|
-
"@delacour/react-native-charts": "
|
|
184
|
+
"@delacour/react-native-bottom-sheet": "0.6.0-alpha.20261030084745",
|
|
185
|
+
"@delacour/react-native-charts": "0.6.0-alpha.20261030084745",
|
|
187
186
|
"@legendapp/list": ">=3.3",
|
|
188
187
|
"react-native-skia": ">=3.7.3",
|
|
189
188
|
"expo-linear-gradient": ">=15",
|
|
@@ -243,6 +242,7 @@
|
|
|
243
242
|
"@types/react": "catalog:",
|
|
244
243
|
"expo-router": "~58.0.24",
|
|
245
244
|
"react-native-teleport": "^1.2.2",
|
|
245
|
+
"tailwind-merge": "^3.6.0",
|
|
246
246
|
"typescript": "~6.0.3"
|
|
247
247
|
}
|
|
248
248
|
}
|
package/src/cn.parity.test.ts
CHANGED
|
@@ -11,8 +11,8 @@ import { type CustomClassGroupId, TW_MERGE_CONFIG } from "./styles/tokens";
|
|
|
11
11
|
* `cn` promises tailwind-merge's output for every input; this holds it to that
|
|
12
12
|
* on the strings that matter here — every class literal in a `*.variants.ts`,
|
|
13
13
|
* merged pairwise with a caller-style override, through the same
|
|
14
|
-
* `TW_MERGE_CONFIG`. tailwind-merge
|
|
15
|
-
*
|
|
14
|
+
* `TW_MERGE_CONFIG`. tailwind-merge is a dev dependency for this alone:
|
|
15
|
+
* tailwind-variants v4 compiles its own tables and no longer peers on it.
|
|
16
16
|
*/
|
|
17
17
|
const reference = extendTailwindMerge<CustomClassGroupId>(TW_MERGE_CONFIG);
|
|
18
18
|
|
|
@@ -15,7 +15,7 @@ import { kpiVariants } from "./kpi.variants";
|
|
|
15
15
|
*/
|
|
16
16
|
export function KpiIcon({ className, children, ...props }: KpiSlotProps): ReactElement {
|
|
17
17
|
const { size, colorIndex } = useKpiPart("Kpi.Icon");
|
|
18
|
-
const slots = kpiVariants({ colorIndex
|
|
18
|
+
const slots = kpiVariants({ colorIndex: `${colorIndex}`, size });
|
|
19
19
|
const glyph = slots.iconGlyph();
|
|
20
20
|
const defaults = useMemo(() => ({ className: glyph, color: `chart-${colorIndex}` }), [glyph, colorIndex]);
|
|
21
21
|
|
|
@@ -25,7 +25,7 @@ function everyCombination(): ReturnType<typeof kpiVariants>[] {
|
|
|
25
25
|
KPI_LAYOUTS.flatMap((layout) =>
|
|
26
26
|
KPI_TONES.flatMap((tone) =>
|
|
27
27
|
KPI_COLOR_INDEXES.flatMap((colorIndex) =>
|
|
28
|
-
CARD_PLANES.map((plane) => kpiVariants({ colorIndex
|
|
28
|
+
CARD_PLANES.map((plane) => kpiVariants({ colorIndex: `${colorIndex}`, layout, plane, size, tone }))
|
|
29
29
|
)
|
|
30
30
|
)
|
|
31
31
|
)
|
|
@@ -344,7 +344,7 @@ describe("kpiVariants trend", () => {
|
|
|
344
344
|
describe("kpiVariants icon", () => {
|
|
345
345
|
test("tints the icon square from the series ramp, one token per colour index", () => {
|
|
346
346
|
for (const colorIndex of KPI_COLOR_INDEXES) {
|
|
347
|
-
expect(kpiVariants({ colorIndex }).icon()).toContain(`bg-chart-${colorIndex}/15`);
|
|
347
|
+
expect(kpiVariants({ colorIndex: `${colorIndex}` }).icon()).toContain(`bg-chart-${colorIndex}/15`);
|
|
348
348
|
}
|
|
349
349
|
});
|
|
350
350
|
|
|
@@ -143,11 +143,11 @@ export const kpiVariants = tv({
|
|
|
143
143
|
neutral: { trendText: "text-muted-foreground" },
|
|
144
144
|
},
|
|
145
145
|
colorIndex: {
|
|
146
|
-
1: { icon: "bg-chart-1/15" },
|
|
147
|
-
2: { icon: "bg-chart-2/15" },
|
|
148
|
-
3: { icon: "bg-chart-3/15" },
|
|
149
|
-
4: { icon: "bg-chart-4/15" },
|
|
150
|
-
5: { icon: "bg-chart-5/15" },
|
|
146
|
+
"1": { icon: "bg-chart-1/15" },
|
|
147
|
+
"2": { icon: "bg-chart-2/15" },
|
|
148
|
+
"3": { icon: "bg-chart-3/15" },
|
|
149
|
+
"4": { icon: "bg-chart-4/15" },
|
|
150
|
+
"5": { icon: "bg-chart-5/15" },
|
|
151
151
|
},
|
|
152
152
|
layout: {
|
|
153
153
|
below: { sparkline: "w-full" },
|
|
@@ -180,7 +180,7 @@ export const kpiVariants = tv({
|
|
|
180
180
|
size: "md",
|
|
181
181
|
plane: "default",
|
|
182
182
|
tone: "neutral",
|
|
183
|
-
colorIndex: 1,
|
|
183
|
+
colorIndex: "1",
|
|
184
184
|
layout: "below",
|
|
185
185
|
orientation: "vertical",
|
|
186
186
|
separated: false,
|
|
@@ -76,7 +76,7 @@ function LeaderboardPodiumBar({
|
|
|
76
76
|
}, [delay, grown, height, isReducedMotion]);
|
|
77
77
|
|
|
78
78
|
const style = useAnimatedStyle(() => ({ height: grown.value }));
|
|
79
|
-
const slots = leaderboardVariants({ place });
|
|
79
|
+
const slots = leaderboardVariants({ place: `${place}` });
|
|
80
80
|
|
|
81
81
|
return (
|
|
82
82
|
<Animated.View className={slots.podiumBar()} style={style}>
|
|
@@ -103,7 +103,7 @@ export function LeaderboardPodiumPlace(props: LeaderboardPodiumPlaceProps): Reac
|
|
|
103
103
|
const { entry, rank } = ranked;
|
|
104
104
|
const place = resolvePodiumPlace(rank);
|
|
105
105
|
const isHighlighted = entry.id === highlightId;
|
|
106
|
-
const slots = leaderboardVariants({ isHighlighted, place });
|
|
106
|
+
const slots = leaderboardVariants({ isHighlighted, place: `${place}` });
|
|
107
107
|
const formattedValue = formatValue(entry.value);
|
|
108
108
|
const accessibilityLabel = resolveLeaderboardAccessibilityLabel({ formattedValue, ranked, unit });
|
|
109
109
|
const media = renderMedia?.(entry, rank);
|
|
@@ -455,10 +455,10 @@ describe("leaderboardVariants", () => {
|
|
|
455
455
|
});
|
|
456
456
|
|
|
457
457
|
test("the three bar tones are primary at 100 / 70 / 45 percent — never a metal", () => {
|
|
458
|
-
expect(leaderboardVariants({ place: 1 }).podiumBar()).toMatch(/\bbg-primary(\s|$)/);
|
|
459
|
-
expect(leaderboardVariants({ place: 2 }).podiumBar()).toContain("bg-primary/70");
|
|
460
|
-
expect(leaderboardVariants({ place: 3 }).podiumBar()).toContain("bg-primary/45");
|
|
461
|
-
for (const place of [1, 2, 3] as const) {
|
|
458
|
+
expect(leaderboardVariants({ place: "1" }).podiumBar()).toMatch(/\bbg-primary(\s|$)/);
|
|
459
|
+
expect(leaderboardVariants({ place: "2" }).podiumBar()).toContain("bg-primary/70");
|
|
460
|
+
expect(leaderboardVariants({ place: "3" }).podiumBar()).toContain("bg-primary/45");
|
|
461
|
+
for (const place of ["1", "2", "3"] as const) {
|
|
462
462
|
expect(leaderboardVariants({ place }).podiumBar()).not.toMatch(/gold|silver|bronze|amber|yellow|orange/);
|
|
463
463
|
}
|
|
464
464
|
});
|
|
@@ -478,8 +478,8 @@ describe("leaderboardVariants", () => {
|
|
|
478
478
|
});
|
|
479
479
|
|
|
480
480
|
test("the cards podium raises the leader by 12pt", () => {
|
|
481
|
-
expect(leaderboardVariants({ place: 1 }).podiumCard()).toContain("mb-3");
|
|
482
|
-
expect(leaderboardVariants({ place: 2 }).podiumCard()).not.toContain("mb-3");
|
|
481
|
+
expect(leaderboardVariants({ place: "1" }).podiumCard()).toContain("mb-3");
|
|
482
|
+
expect(leaderboardVariants({ place: "2" }).podiumCard()).not.toContain("mb-3");
|
|
483
483
|
});
|
|
484
484
|
|
|
485
485
|
test("no view slot carries a text colour or size (rule 1)", () => {
|
|
@@ -499,7 +499,7 @@ describe("leaderboardVariants", () => {
|
|
|
499
499
|
] as const;
|
|
500
500
|
for (const isHighlighted of [true, false]) {
|
|
501
501
|
for (const place of [1, 2, 3] as const) {
|
|
502
|
-
const variant = leaderboardVariants({ isHighlighted, place });
|
|
502
|
+
const variant = leaderboardVariants({ isHighlighted, place: `${place}` });
|
|
503
503
|
for (const slot of viewSlots) {
|
|
504
504
|
expect(variant[slot]()).not.toMatch(/\btext-(?!center|left|right)/);
|
|
505
505
|
}
|
|
@@ -80,9 +80,9 @@ export const leaderboardVariants = tv({
|
|
|
80
80
|
},
|
|
81
81
|
variants: {
|
|
82
82
|
place: {
|
|
83
|
-
1: { podiumBar: "bg-primary", podiumCard: "mb-3", podiumCardRank: "bg-primary" },
|
|
84
|
-
2: { podiumBar: "bg-primary/70", podiumCardRank: "bg-primary/70" },
|
|
85
|
-
3: { podiumBar: "bg-primary/45", podiumCardRank: "bg-primary/45" },
|
|
83
|
+
"1": { podiumBar: "bg-primary", podiumCard: "mb-3", podiumCardRank: "bg-primary" },
|
|
84
|
+
"2": { podiumBar: "bg-primary/70", podiumCardRank: "bg-primary/70" },
|
|
85
|
+
"3": { podiumBar: "bg-primary/45", podiumCardRank: "bg-primary/45" },
|
|
86
86
|
},
|
|
87
87
|
isHighlighted: {
|
|
88
88
|
true: { row: "bg-primary/10", podiumCard: "border-primary" },
|
|
@@ -214,13 +214,13 @@ function distinctColors(
|
|
|
214
214
|
|
|
215
215
|
/** The class that paints a colour as a fill, or `undefined` for a literal, which is painted by style. */
|
|
216
216
|
function swatchClass(color: PlannerEntryColor): string | undefined {
|
|
217
|
-
if (color.kind === "chart") return plannerVariants({ colorIndex: color.index }).swatch();
|
|
217
|
+
if (color.kind === "chart") return plannerVariants({ colorIndex: `${color.index}` }).swatch();
|
|
218
218
|
return color.kind === "default" ? "bg-primary" : undefined;
|
|
219
219
|
}
|
|
220
220
|
|
|
221
221
|
/** The same colour at 15%, for a block or tile behind text — `undefined` for a literal. */
|
|
222
222
|
function tintClass(color: PlannerEntryColor): string | undefined {
|
|
223
|
-
if (color.kind === "chart") return plannerVariants({ colorIndex: color.index }).tint();
|
|
223
|
+
if (color.kind === "chart") return plannerVariants({ colorIndex: `${color.index}` }).tint();
|
|
224
224
|
return color.kind === "default" ? "bg-primary/15" : undefined;
|
|
225
225
|
}
|
|
226
226
|
|
|
@@ -39,7 +39,7 @@ export function PlannerLegend({ counts = false, className, children, ...props }:
|
|
|
39
39
|
<View
|
|
40
40
|
className={cn(
|
|
41
41
|
slots.legendDot(),
|
|
42
|
-
color.kind === "chart" ? plannerVariants({ colorIndex: color.index }).swatch() : undefined,
|
|
42
|
+
color.kind === "chart" ? plannerVariants({ colorIndex: `${color.index}` }).swatch() : undefined,
|
|
43
43
|
color.kind === "default" ? "bg-primary" : undefined
|
|
44
44
|
)}
|
|
45
45
|
style={color.kind === "literal" ? { backgroundColor: color.color } : undefined}
|
|
@@ -291,7 +291,7 @@ describe("plannerVariants", () => {
|
|
|
291
291
|
test("every colour token named by a slot exists in both themes", () => {
|
|
292
292
|
const used = new Set<string>();
|
|
293
293
|
for (const colorIndex of PLANNER_COLOR_INDEXES) {
|
|
294
|
-
for (const slots of [...everyCombination(), plannerVariants({ colorIndex })]) {
|
|
294
|
+
for (const slots of [...everyCombination(), plannerVariants({ colorIndex: `${colorIndex}` })]) {
|
|
295
295
|
for (const fn of Object.values(slots)) {
|
|
296
296
|
for (const match of (fn() ?? "").matchAll(
|
|
297
297
|
/(?:^|\s)(?:bg|text|border)-([a-z][a-z0-9-]*?)(?:\/\d+)?(?=\s|$)/g
|
|
@@ -347,8 +347,8 @@ describe("plannerVariants", () => {
|
|
|
347
347
|
|
|
348
348
|
test("a chart tint class exists for every colour slot", () => {
|
|
349
349
|
for (const colorIndex of PLANNER_COLOR_INDEXES) {
|
|
350
|
-
expect(plannerVariants({ colorIndex }).swatch()).toContain(`bg-chart-${colorIndex}`);
|
|
351
|
-
expect(plannerVariants({ colorIndex }).tint()).toContain(`bg-chart-${colorIndex}/15`);
|
|
350
|
+
expect(plannerVariants({ colorIndex: `${colorIndex}` }).swatch()).toContain(`bg-chart-${colorIndex}`);
|
|
351
|
+
expect(plannerVariants({ colorIndex: `${colorIndex}` }).tint()).toContain(`bg-chart-${colorIndex}/15`);
|
|
352
352
|
}
|
|
353
353
|
});
|
|
354
354
|
});
|
|
@@ -127,11 +127,11 @@ export const plannerVariants = tv({
|
|
|
127
127
|
false: {},
|
|
128
128
|
},
|
|
129
129
|
colorIndex: {
|
|
130
|
-
1: { swatch: "bg-chart-1", tint: "bg-chart-1/15" },
|
|
131
|
-
2: { swatch: "bg-chart-2", tint: "bg-chart-2/15" },
|
|
132
|
-
3: { swatch: "bg-chart-3", tint: "bg-chart-3/15" },
|
|
133
|
-
4: { swatch: "bg-chart-4", tint: "bg-chart-4/15" },
|
|
134
|
-
5: { swatch: "bg-chart-5", tint: "bg-chart-5/15" },
|
|
130
|
+
"1": { swatch: "bg-chart-1", tint: "bg-chart-1/15" },
|
|
131
|
+
"2": { swatch: "bg-chart-2", tint: "bg-chart-2/15" },
|
|
132
|
+
"3": { swatch: "bg-chart-3", tint: "bg-chart-3/15" },
|
|
133
|
+
"4": { swatch: "bg-chart-4", tint: "bg-chart-4/15" },
|
|
134
|
+
"5": { swatch: "bg-chart-5", tint: "bg-chart-5/15" },
|
|
135
135
|
},
|
|
136
136
|
},
|
|
137
137
|
compoundVariants: [
|
|
@@ -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
|
+
}
|
package/src/lib/tv.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { createTV } from "tailwind-variants";
|
|
1
|
+
import { createTV } from "tailwind-variants/config";
|
|
2
2
|
import { TW_MERGE_CONFIG } from "../styles/tokens";
|
|
3
3
|
|
|
4
4
|
/**
|
|
@@ -9,6 +9,9 @@ import { TW_MERGE_CONFIG } from "../styles/tokens";
|
|
|
9
9
|
* through a tailwind-merge instance it builds itself, entirely separate from
|
|
10
10
|
* the one behind `cn()`, and a bare `tv` does not know what `button-md` is.
|
|
11
11
|
*
|
|
12
|
+
* `tailwind-variants/config`, not the default entry: since v4 the default
|
|
13
|
+
* entry ships only the compiled default tables and throws on `twMergeConfig`.
|
|
14
|
+
*
|
|
12
15
|
* That is not a cosmetic gap. An unrecognised `text-button-md` lands in
|
|
13
16
|
* tailwind-merge's text *colour* group alongside `text-primary-foreground`, the
|
|
14
17
|
* two are judged to conflict, and the label loses its colour — no error, just a
|