@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 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.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-merge": "^3.6.0",
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": ">=0.3.0",
186
- "@delacour/react-native-charts": ">=0.2.0",
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
  }
@@ -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 stays installed as `tailwind-variants`'
15
- * peer, which is what makes the reference available.
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, size });
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, layout, plane, size, tone }))
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, 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
+ }
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