feat(count-up): animated STAT — prefix/suffix + currency/percent/compact (not a bare counter)

Looking at the original seed demo (a big bold "1,000+" stat), the ported
CountUp was impoverished: a plain grouped decimal, no affixes, no currency/
percent — "just a countdown, you don't need an animation for that".

- Component: add the affixes the seed had (`prefix`/`suffix`, each in its own
  span) and the FULL `<FormatNumber>` option surface (`formatStyle` currency/
  percent/unit · `notation` compact · fraction digits) — delivering the D-T2
  "both share the same formatter" promise: a count animates toward a currency,
  a percent or a compact value, not just a decimal. Render with
  `font-variant-numeric: tabular-nums` inline so digits don't jitter while
  counting (no CSS recipe — E-2.2 exception intact).
- Fix a fraction-digits bug: min-fraction inherited the raw value's decimals
  (0.999 → "99,900%"); default min to 0 so `maximumFractionDigits` governs.
- Demo: present it as an animated STAT — big/bold/tabular-nums, suffix "+",
  a preset row (12.500+ users · $1,2M · 99,9% · ↓3,2s) and format controls.

Verified in real Chrome: "382+" counts to "12.500+"; the $1,2M preset animates
compact EUR ("36,4 mil €" → "1,2 mill €"). Matrix 141/0/0, check clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
menubar-v4-safe
dev 3 months ago
parent 6da8466f68
commit c421baab65

@ -11,14 +11,22 @@ contar es de CountUp; ambos comparten el mismo formatter del ecosistema.
## Superficie
El caso de uso real es una **estadística animada de titular** — un número grande
que cuenta al entrar en vista, con afijos y formato:
```svelte
<CountUp to={12500} />
<CountUp to={99.9} from={20} duration={3} />
<CountUp to={500} direction="down" delay={0.5} />
<CountUp to={1000000} useGrouping={false} />
<CountUp to={42} startWhen={ready} onStart={...} onEnd={...} />
<CountUp to={12500} suffix="+" /> <!-- 12.500+ -->
<CountUp to={1200000} formatStyle="currency" currency="EUR" notation="compact" /> <!-- 1,2 mill € -->
<CountUp to={0.999} formatStyle="percent" maximumFractionDigits={1} /> <!-- 99,9% -->
<CountUp to={3.2} from={8} direction="down" suffix="s" maximumFractionDigits={1} /> <!-- 3,2s -->
<CountUp to={2048} formatStyle="unit" unit="byte" notation="compact" />
```
Renderiza `data-count-up` con `font-variant-numeric: tabular-nums` (los dígitos
no bailan de ancho mientras cuenta) y, si hay `prefix`/`suffix`, cada afijo en
su propio span (`[data-count-up-prefix]` / `[data-count-up-value]` /
`[data-count-up-suffix]`) para que el consumidor pueda estilarlos.
## Props clave
| Prop | Tipo | Default | Notas |
@ -30,6 +38,11 @@ contar es de CountUp; ambos comparten el mismo formatter del ecosistema.
| `duration` | `number` (s) | `2` | Duración aproximada (deriva stiffness/damping del muelle). |
| `startWhen` | `boolean` | `true` | Gate adicional al viewport. |
| `useGrouping` | `boolean` | runtime | Separador de millares on/off — el locale decide CUÁL. |
| `formatStyle` | `'decimal' \| 'currency' \| 'percent' \| 'unit'` | `'decimal'` | Mismo vocabulario que `<FormatNumber>` (mismo formatter). |
| `currency` / `unit` | `string` | runtime / — | Para `formatStyle` `currency` / `unit`. |
| `notation` | `'standard' \| 'compact'` | `'standard'` | `compact` → "1,2M". |
| `maximumFractionDigits` / `minimumFractionDigits` | `number` | auto | Precisión del display. |
| `prefix` / `suffix` | `string` | `''` | Afijos estáticos (`$`, `+`, `%`…) en su propio span. |
| `locale` | BCP-47 | runtime | Override por instancia (tercer arg del runtime). |
| `onStart` / `onEnd` | `() => void` | — | Callbacks camelCase. |
| `as` | `keyof HTMLElementTagNameMap` | `'span'` | Tag HTML. |
@ -41,6 +54,13 @@ contar es de CountUp; ambos comparten el mismo formatter del ecosistema.
`Intl.NumberFormat('en-US')` + un hack de `replace` para el separador.
- **El prop `separator` del seed NO se porta**: los separadores son del
locale/preferencias del runtime; `useGrouping` cubre el on/off.
- **Sí se portan `prefix`/`suffix` del seed** (los afijos que lo hacen un
stat, "12.500+" / "3,2s"), en spans propios con `tabular-nums` para que el
número no salte de ancho al contar.
- **Superficie de formato completa de `<FormatNumber>`** (`formatStyle` ·
`currency` · `unit` · `notation` · fracciones) — cumple la promesa D-T2 de
"ambos comparten el mismo formatter": un count anima hacia una moneda, un
porcentaje o un compacto, no solo un decimal pelado.
- **Decimales derivados de `from`/`to`** (los que muestre el valor con más
precisión), con umbral de reposo `0.5 × 10^-decimales` — el muelle para
en cuanto el display ya no puede cambiar.

@ -29,6 +29,15 @@
duration = 2,
startWhen = true,
useGrouping,
formatStyle = 'decimal',
currency,
unit,
notation,
compactDisplay,
maximumFractionDigits,
minimumFractionDigits,
prefix = '',
suffix = '',
locale,
onStart,
onEnd,
@ -52,11 +61,26 @@
const maxDecimals = $derived(Math.max(getDecimalPlaces(from), getDecimalPlaces(to)));
function formatValue(latest: number, decimals: number): string {
// Same option surface as <FormatNumber> (both route through the runtime
// formatter) — so a count can animate a currency / percent / compact
// value, not just a plain decimal.
const minFrac = minimumFractionDigits ?? 0;
const maxFrac = maximumFractionDigits ?? (decimals > 0 ? decimals : 0);
const options: Intl.NumberFormatOptions = {
style: formatStyle,
...(useGrouping !== undefined && { useGrouping }),
minimumFractionDigits: decimals > 0 ? decimals : 0,
maximumFractionDigits: decimals > 0 ? decimals : 0
...(currency && { currency }),
...(unit && { unit }),
...(notation && { notation }),
...(compactDisplay && { compactDisplay }),
minimumFractionDigits: minFrac,
maximumFractionDigits: Math.max(minFrac, maxFrac)
};
// Currency style with no explicit prop → pull the active currency.
if (formatStyle === 'currency' && !currency) {
const active = eidos.format?.currency?.getCurrency?.();
if (active) options.currency = active;
}
const numbers = eidos.format?.numbers;
if (numbers) return numbers.format(latest, options, locale);
// No runtime available (e.g. eidos used outside `<UixApp>`).
@ -172,4 +196,12 @@
});
</script>
<svelte:element this={as} bind:this={spanEl} data-count-up="" {...rest}></svelte:element>
<svelte:element
this={as}
data-count-up=""
style="font-variant-numeric: tabular-nums;"
{...rest}
>{#if prefix}<span data-count-up-prefix="">{prefix}</span>{/if}<span
bind:this={spanEl}
data-count-up-value=""
></span>{#if suffix}<span data-count-up-suffix="">{suffix}</span>{/if}</svelte:element>

@ -32,6 +32,27 @@ export type CountUpProps = Omit<HTMLAttributes<HTMLElement>, 'children' | 'style
* separator — this component never hardcodes one. @default true
*/
useGrouping?: boolean;
/**
* Formatting style — the same vocabulary as `<FormatNumber>` (both route
* through `uix.format.numbers`). Animate toward a currency, a percentage
* or a unit, not just a plain decimal. @default 'decimal'
*/
formatStyle?: 'decimal' | 'currency' | 'percent' | 'unit';
/** ISO 4217 code, required when `formatStyle="currency"` (defaults to the runtime currency). */
currency?: string;
/** Unit identifier, required when `formatStyle="unit"` (e.g. `'byte'`). */
unit?: string;
/** `'compact'` renders "1.2K" / "1.2M". @default 'standard' */
notation?: 'standard' | 'compact';
/** Compact variant when `notation="compact"`. @default 'short' */
compactDisplay?: 'short' | 'long';
/** Cap fractional digits (e.g. `0` for whole-number stats). */
maximumFractionDigits?: number;
minimumFractionDigits?: number;
/** Static text before the number (e.g. `'$'`). Rendered in its own span. */
prefix?: string;
/** Static text after the number (e.g. `'+'`, `'%'`, `' users'`). */
suffix?: string;
/**
* Optional locale override (BCP-47). Defaults to the active locale
* from `uix.format.numbers`.

@ -15,12 +15,19 @@
type DemoLocale = 'es-ES' | 'en-US' | 'de-DE' | 'fr-FR' | 'ja-JP' | 'ar-EG';
const DEMO_LOCALES: readonly DemoLocale[] = ['es-ES', 'en-US', 'de-DE', 'fr-FR', 'ja-JP', 'ar-EG'];
let to = $state(1234567.89);
type FormatStyle = 'decimal' | 'currency' | 'percent';
let to = $state(12500);
let from = $state(0);
let direction = $state<'up' | 'down'>('up');
let delay = $state(0);
let duration = $state(2);
let duration = $state(2.4);
let useGrouping = $state(true);
let formatStyle = $state<FormatStyle>('decimal');
let compact = $state(false);
let maximumFractionDigits = $state<number | undefined>(0);
let prefix = $state('');
let suffix = $state('+');
let demoLocale = $state<DemoLocale>('es-ES');
// Remount key: CountUp runs once per viewport entry; the demo re-runs it
@ -28,6 +35,33 @@
let runId = $state(0);
const rerun = () => (runId += 1);
// Preset stats — the real use case: an animated headline number.
type Preset = { label: string; apply: () => void };
const PRESETS: Preset[] = [
{
label: '12.500+ usuarios',
apply: () => { to = 12500; from = 0; formatStyle = 'decimal'; compact = false; maximumFractionDigits = 0; prefix = ''; suffix = '+'; rerun(); }
},
{
label: '$1,2M ingresos',
apply: () => { to = 1200000; from = 0; formatStyle = 'currency'; compact = true; maximumFractionDigits = 1; prefix = ''; suffix = ''; rerun(); }
},
{
label: '99,9% uptime',
apply: () => { to = 99.9; from = 0; formatStyle = 'percent'; compact = false; maximumFractionDigits = 1; prefix = ''; suffix = ''; rerun(); }
},
{
label: '↓ 3,2s carga',
apply: () => { to = 3.2; from = 8; direction = 'down'; formatStyle = 'decimal'; compact = false; maximumFractionDigits = 1; prefix = ''; suffix = 's'; rerun(); }
}
];
// percent style expects the value pre-divided; the demo passes 0-100, so
// scale to a fraction for the component (it multiplies back by 100).
const value = $derived(formatStyle === 'percent' ? to / 100 : to);
const valueFrom = $derived(formatStyle === 'percent' ? from / 100 : from);
const notation = $derived(compact ? ('compact' as const) : undefined);
const eidosSnippet = $derived(
[
"<script lang='ts'>",
@ -35,11 +69,17 @@
'</' + 'script>',
'',
'<CountUp',
` to={${to}}`,
from !== 0 && ` from={${from}}`,
` to={${value}}`,
valueFrom !== 0 && ` from={${valueFrom}}`,
direction !== 'up' && ` direction="${direction}"`,
delay !== 0 && ` delay={${delay}}`,
formatStyle !== 'decimal' && ` formatStyle="${formatStyle}"`,
formatStyle === 'currency' && ' currency="EUR"',
compact && ' notation="compact"',
maximumFractionDigits !== undefined && ` maximumFractionDigits={${maximumFractionDigits}}`,
prefix && ` prefix="${prefix}"`,
suffix && ` suffix="${suffix}"`,
duration !== 2 && ` duration={${duration}}`,
delay !== 0 && ` delay={${delay}}`,
!useGrouping && ' useGrouping={false}',
` locale="${demoLocale}"`,
'/>'
@ -70,16 +110,37 @@
</div>
</header>
<!-- Live preview always rendered -->
<!-- Live preview — a big animated stat headline (the real use case) -->
<div data-uix-stage>
<div data-uix-stage-area style="font-size: var(--font-size-3xl); font-weight: 700; background: var(--color-surface-default); color: var(--color-neutral-text);">
{#key `${runId}|${to}|${from}|${direction}|${delay}|${duration}|${useGrouping}|${demoLocale}`}
<CountUp {to} {from} {direction} {delay} {duration} {useGrouping} locale={demoLocale} />
<div
data-uix-stage-area
style="background: var(--color-surface-default); color: var(--color-neutral-text); font-size: clamp(3rem, 9vw, 6rem); font-weight: 800; letter-spacing: -0.02em;"
>
{#key `${runId}|${value}|${valueFrom}|${direction}|${delay}|${duration}|${useGrouping}|${formatStyle}|${compact}|${maximumFractionDigits}|${prefix}|${suffix}|${demoLocale}`}
<CountUp
to={value}
from={valueFrom}
{direction}
{delay}
{duration}
{useGrouping}
{formatStyle}
currency={formatStyle === 'currency' ? 'EUR' : undefined}
{notation}
{maximumFractionDigits}
{prefix}
{suffix}
locale={demoLocale}
/>
{/key}
</div>
<div data-uix-stage-trace>
<span data-uix-stage-trace-key>trace</span>
<span>no semantic events (service component)</span>
<span data-uix-stage-trace-key>preset</span>
<span data-uix-chips role="radiogroup" style="flex-wrap: wrap;">
{#each PRESETS as p (p.label)}
<button data-uix-chip onclick={p.apply}>{p.label}</button>
{/each}
</span>
<span style="margin-inline-start: auto;">
<button data-uix-chip onclick={rerun}>↻ re-run</button>
</span>
@ -110,6 +171,39 @@
locale, nunca un prop de separador.
</p>
<div data-uix-subsection-head>
<span data-uix-layer-badge="eidos">eidos</span> props · format (hereda de FormatNumber)
</div>
<div data-uix-controls>
<label data-uix-control>
<span data-uix-control-label>formatStyle</span>
<span data-uix-chips role="radiogroup">
{#each ['decimal', 'currency', 'percent'] as const as opt (opt)}
<button data-uix-chip data-active={formatStyle === opt} onclick={() => (formatStyle = opt)}>{opt}</button>
{/each}
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>notation compact <span data-uix-control-hint>1,2M</span></span>
<span data-uix-chips role="radiogroup">
<button data-uix-chip data-active={!compact} onclick={() => (compact = false)}>standard</button>
<button data-uix-chip data-active={compact} onclick={() => (compact = true)}>compact</button>
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>maximumFractionDigits</span>
<input type="number" min="0" max="4" step="1" value={maximumFractionDigits ?? 0} oninput={(e) => (maximumFractionDigits = Number(e.currentTarget.value))} style="inline-size: 6rem;" />
</label>
<label data-uix-control>
<span data-uix-control-label>prefix <span data-uix-control-hint>$, €…</span></span>
<input type="text" bind:value={prefix} style="inline-size: 6rem;" />
</label>
<label data-uix-control>
<span data-uix-control-label>suffix <span data-uix-control-hint>+, %, ↑…</span></span>
<input type="text" bind:value={suffix} style="inline-size: 6rem;" />
</label>
</div>
<div data-uix-subsection-head>
<span data-uix-layer-badge="eidos">eidos</span> props · count
</div>
@ -186,6 +280,12 @@
<tr><td class="name">duration</td><td class="type">number (s)</td><td class="default">2</td><td>Deriva stiffness/damping del muelle.</td></tr>
<tr><td class="name">startWhen</td><td class="type">boolean</td><td class="default">true</td><td>Gate adicional al viewport.</td></tr>
<tr><td class="name">useGrouping</td><td class="type">boolean</td><td class="default">runtime</td><td>Separador de millares on/off — el locale decide CUÁL.</td></tr>
<tr><td class="name">formatStyle</td><td class="type">'decimal' | 'currency' | 'percent' | 'unit'</td><td class="default">'decimal'</td><td>Mismo vocabulario que <code>&lt;FormatNumber&gt;</code> — anima hacia una moneda / porcentaje / unidad.</td></tr>
<tr><td class="name">currency</td><td class="type">ISO 4217</td><td class="default">runtime</td><td>Requerido con <code>formatStyle="currency"</code>.</td></tr>
<tr><td class="name">unit</td><td class="type">string</td><td class="default">—</td><td>Requerido con <code>formatStyle="unit"</code> (<code>'byte'</code>…).</td></tr>
<tr><td class="name">notation</td><td class="type">'standard' | 'compact'</td><td class="default">'standard'</td><td><code>compact</code> → "1,2M".</td></tr>
<tr><td class="name">maximumFractionDigits / minimumFractionDigits</td><td class="type">number</td><td class="default">auto</td><td>Precisión fraccional del display.</td></tr>
<tr><td class="name">prefix / suffix</td><td class="type">string</td><td class="default">''</td><td>Texto estático alrededor del número (<code>$</code>, <code>+</code>, <code>%</code>…) en su propio span.</td></tr>
<tr><td class="name">locale</td><td class="type">BCP-47</td><td class="default">runtime</td><td>Override por instancia (tercer arg del runtime).</td></tr>
<tr><td class="name">onStart / onEnd</td><td class="type">() =&gt; void</td><td class="default">—</td><td>Callbacks camelCase.</td></tr>
<tr><td class="name">as</td><td class="type">tag</td><td class="default">'span'</td><td>Elemento renderizado.</td></tr>

Loading…
Cancel
Save

Powered by TurnKey Linux.