La fila de números que respalda lo que la página acaba de afirmar. Compound
(`.Stat` repite, sin contexto): `<StatsBand>` + `.Stat` + `.Value` + `.Label`.
- **Compone, no reinventa**: la semántica de KPI es el `Metrics` del canon, leído
de su API y pasado tal cual (precedente `faq`/`Accordion`). `Metrics` es
surfaceless, que es exactamente lo que una banda necesita: no son tarjetas de
panel. Un delta, un icono o un sparkline son `Metrics.Delta`/`.Icon`/`.Chart`
que la app compone dentro — el block no los re-expone.
- **La superación literal del dossier**: `<StatsBand.Value count={12500} />`
compone `CountUp`, así que la cifra se cuenta sola al entrar en pantalla.
NINGUNA referencia puede shipearlo: todas entregan markup estático. El formato
locale-aware y el salto directo bajo reduced-motion vienen del `CountUp`, no
de aquí.
- **`count` es opt-in, nunca default**: una cifra que se anima sin que el lector
lo pida es ruido, y algunas no son contables («99,98 %»). Sin `count`, la
cifra la pone la app por children.
- **Ni una palabra ni un separador salen del block** (B-7): las palabras por
children, el formato por `uix.format` dentro del `CountUp`.
- Escalonado estructural: `data-stagger` en la banda y cada `.Stat` es un
`Motion trigger="viewport"`, así que las cifras aterrizan una tras otra sin un
solo milisegundo escrito a mano.
Verificado en navegador (con la banda bajo el pliegue, para cazar el conteo desde
el primer fotograma): 4 cifras con índices estructurales 0,1,2,3 contando
—9377→11.678 · 255→318 · 36→45—, el porcentaje quieto por no ser contable, y el
separador de millares por locale (`11.678`). Cero errores de página.
`blocks:check` verde (8 blocks) · `svelte-check` sin errores propios.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
alpha-0.1-sec-dom
parent
c39170abbf
commit
4e785d32bf
@ -0,0 +1,46 @@
|
||||
// StatsBand — the row of figures that backs the claims a page just made.
|
||||
// Compound (its `Stat` repeats), composing the canon `Metrics` for the KPI
|
||||
// semantics and `CountUp` for the figure that counts itself up.
|
||||
//
|
||||
// import { StatsBand } from '$blocks/stats-band';
|
||||
//
|
||||
// <StatsBand>
|
||||
// <StatsBand.Stat>
|
||||
// <StatsBand.Value count={12500} />
|
||||
// <StatsBand.Label>Eventos por segundo</StatsBand.Label>
|
||||
// </StatsBand.Stat>
|
||||
// <StatsBand.Stat>
|
||||
// <StatsBand.Value>99,98 %</StatsBand.Value>
|
||||
// <StatsBand.Label>Disponibilidad</StatsBand.Label>
|
||||
// </StatsBand.Stat>
|
||||
// </StatsBand>
|
||||
//
|
||||
// `count` is the one-up: no reference can ship a count-up, because they all ship
|
||||
// static markup. Omit it and the app renders the value itself (B-7 — every word
|
||||
// and every number is the app's). The stagger is structural: `data-stagger` on
|
||||
// the band, each `Stat` a viewport `Motion`.
|
||||
import StatsBandComponent from './stats-band.svelte';
|
||||
import Stat from './stats-band-stat.svelte';
|
||||
import Value from './stats-band-value.svelte';
|
||||
import Label from './stats-band-label.svelte';
|
||||
|
||||
type StatsBandNamespace = typeof StatsBandComponent & {
|
||||
Stat: typeof Stat;
|
||||
Value: typeof Value;
|
||||
Label: typeof Label;
|
||||
};
|
||||
|
||||
const StatsBand = StatsBandComponent as StatsBandNamespace;
|
||||
StatsBand.Stat = Stat;
|
||||
StatsBand.Value = Value;
|
||||
StatsBand.Label = Label;
|
||||
|
||||
export { StatsBand };
|
||||
export default StatsBand;
|
||||
|
||||
export type {
|
||||
StatsBandProps,
|
||||
StatsBandStatProps as StatProps,
|
||||
StatsBandValueProps as ValueProps,
|
||||
StatsBandLabelProps as LabelProps
|
||||
} from './types';
|
||||
@ -0,0 +1,12 @@
|
||||
<script lang="ts">
|
||||
/** StatsBand.Label — what the figure counts, inside the canon `Metrics.Label`.
|
||||
* The words are the app's. */
|
||||
import { Metrics } from '$uix/eidos/components/metrics';
|
||||
import type { StatsBandLabelProps } from './types';
|
||||
|
||||
let { children, ...rest }: StatsBandLabelProps = $props();
|
||||
</script>
|
||||
|
||||
<Metrics.Label {...rest}>
|
||||
{@render children?.()}
|
||||
</Metrics.Label>
|
||||
@ -0,0 +1,22 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* StatsBand.Stat — one figure. IS the viewport `Motion`, so it must stay the
|
||||
* DIRECT child of the `[data-stagger]` band for the foundation to hand it its
|
||||
* structural index; inside, the canon `Metrics` gives the KPI semantics
|
||||
* (label / value / delta) without this block inventing any.
|
||||
*
|
||||
* `align` defaults to `center`: a band reads as a row of centred figures,
|
||||
* unlike a dashboard tile, which is start-aligned.
|
||||
*/
|
||||
import { Metrics } from '$uix/eidos/components/metrics';
|
||||
import { Motion } from '$uix/eidos/components/motion';
|
||||
import type { StatsBandStatProps } from './types';
|
||||
|
||||
let { align = 'center', size = 'lg', children, ...rest }: StatsBandStatProps = $props();
|
||||
</script>
|
||||
|
||||
<Motion trigger="viewport">
|
||||
<Metrics {align} {size} {...rest}>
|
||||
{@render children?.()}
|
||||
</Metrics>
|
||||
</Motion>
|
||||
@ -0,0 +1,26 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* StatsBand.Value — the figure itself, inside the canon `Metrics.Value`.
|
||||
*
|
||||
* With `count`, it composes `CountUp`: the number counts up when the band
|
||||
* scrolls into view. That is the tier's literal one-up — a static markup dump
|
||||
* (which is what every reference ships) CANNOT do it. Locale-aware formatting
|
||||
* and the reduced-motion jump-to-final both come from `CountUp`, not from here.
|
||||
*
|
||||
* Without `count`, the app renders the value itself through `children` (already
|
||||
* formatted, or composed with `FormatNumber`). The block never formats.
|
||||
*/
|
||||
import { Metrics } from '$uix/eidos/components/metrics';
|
||||
import { CountUp } from '$uix/eidos/components/count-up';
|
||||
import type { StatsBandValueProps } from './types';
|
||||
|
||||
let { count, countOptions, children, ...rest }: StatsBandValueProps = $props();
|
||||
</script>
|
||||
|
||||
<Metrics.Value value={count} {...rest}>
|
||||
{#if count !== undefined}
|
||||
<CountUp to={count} {...countOptions} />
|
||||
{:else}
|
||||
{@render children?.()}
|
||||
{/if}
|
||||
</Metrics.Value>
|
||||
@ -0,0 +1,46 @@
|
||||
import type { Snippet } from 'svelte';
|
||||
import type { HTMLAttributes } from 'svelte/elements';
|
||||
import type { ContainerSize } from '$uix/eidos/components/container';
|
||||
import type { SectionSize } from '$uix/eidos/components/section';
|
||||
import type { MetricsProps, MetricsLabelProps, MetricsValueProps } from '$uix/eidos/components/metrics';
|
||||
import type { CountUpProps } from '$uix/eidos/components/count-up';
|
||||
|
||||
// Sub-parts extend the props of the canon component they wrap, never
|
||||
// `HTMLAttributes`: the raw attribute surface clashes with the canon's refined
|
||||
// `style`/`class` when spread through (the feature-grid lesson).
|
||||
export type StatsBandProps = Omit<HTMLAttributes<HTMLElement>, 'children'> & {
|
||||
/** Content measure. Passed straight to `Container`. @default 'lg' */
|
||||
container?: ContainerSize;
|
||||
/** Block-axis padding. Passed straight to `Section`. @default 'lg' */
|
||||
size?: SectionSize;
|
||||
/** Fixed column count. Omit to let the band fit as many as the measure allows. */
|
||||
columns?: number;
|
||||
/** Minimum inline-size per stat before wrapping. @default '12rem' */
|
||||
minChildWidth?: string;
|
||||
/** Gap between stats (space scale). @default 8 */
|
||||
gap?: number;
|
||||
/** The `Stat`s. */
|
||||
children?: Snippet;
|
||||
};
|
||||
|
||||
/** Wraps a `Metrics` (surfaceless). `align` defaults to `center` — a band reads
|
||||
* as a row of centred figures, unlike a dashboard tile. */
|
||||
export type StatsBandStatProps = MetricsProps;
|
||||
|
||||
export type StatsBandValueProps = Omit<MetricsValueProps, 'children'> & {
|
||||
/**
|
||||
* The figure to COUNT UP to when the band scrolls into view — the one thing a
|
||||
* static markup dump cannot ship. Formatting is locale-aware (`uix.format`)
|
||||
* and reduced-motion jumps straight to the final value, both from `CountUp`.
|
||||
* Omit it and the app renders the value itself through `children`.
|
||||
*/
|
||||
count?: number;
|
||||
/** Everything `CountUp` takes (`from`, `duration`, `notation`, `style`…) when
|
||||
* `count` is set. `to` comes from `count`. */
|
||||
countOptions?: Omit<CountUpProps, 'to'>;
|
||||
/** The value as the app renders it — used when `count` is not set. */
|
||||
children?: Snippet;
|
||||
};
|
||||
|
||||
/** Wraps a `Metrics.Label`. */
|
||||
export type StatsBandLabelProps = MetricsLabelProps;
|
||||
@ -0,0 +1,190 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* StatsBand demo. Full-bleed on the page; device widths from `./preview`.
|
||||
*/
|
||||
import { Stack } from '$uix/eidos/components/stack';
|
||||
import { Group } from '$uix/eidos/components/group';
|
||||
import { Wrap } from '$uix/eidos/components/wrap';
|
||||
import { Text } from '$uix/eidos/components/text';
|
||||
import { Code } from '$uix/eidos/components/code';
|
||||
import { ToggleGroup } from '$uix/eidos/components/toggle-group';
|
||||
import BlockDemo from '../_lib/BlockDemo.svelte';
|
||||
import StatsBandSite from './StatsBandSite.svelte';
|
||||
import DocRow from '../_lib/DocRow.svelte';
|
||||
|
||||
let columns = $state(0); // 0 = fluido
|
||||
let withCount = $state(true);
|
||||
let dir = $state<'ltr' | 'rtl'>('ltr');
|
||||
|
||||
const previewSrc = $derived(
|
||||
`/blocks/stats-band/preview?columns=${columns}&count=${withCount}&dir=${dir}`
|
||||
);
|
||||
</script>
|
||||
|
||||
<BlockDemo
|
||||
name="Stats band"
|
||||
slug="$blocks/stats-band"
|
||||
{previewSrc}
|
||||
previewHeight="620px"
|
||||
meta={[
|
||||
{ key: 'compone', value: 'Section · Container · AutoGrid · Metrics · CountUp · Motion' },
|
||||
{ key: 'tipo', value: 'compound (.Stat repite)' },
|
||||
{ key: 'superación', value: 'count-up (ninguna ref puede)' },
|
||||
{ key: 'strings propias', value: '0' }
|
||||
]}
|
||||
>
|
||||
{#snippet preview()}
|
||||
<StatsBandSite {columns} {withCount} />
|
||||
{/snippet}
|
||||
|
||||
{#snippet lede()}
|
||||
La fila de cifras que respalda lo que la página acaba de afirmar. Compone el <Code>Metrics</Code>
|
||||
del canon para la semántica de KPI y <Code>CountUp</Code> para la cifra que se cuenta sola al
|
||||
entrar en pantalla — <strong>lo único de este tier que ninguna referencia puede shipear</strong>,
|
||||
porque todas entregan markup estático.
|
||||
{/snippet}
|
||||
|
||||
{#snippet controls()}
|
||||
<Wrap gap={5}>
|
||||
<Group gap={2} align="center" justify="start">
|
||||
<Text size="sm" color="muted">columnas</Text>
|
||||
<ToggleGroup
|
||||
selectionMode="single"
|
||||
size="sm"
|
||||
attached
|
||||
value={[String(columns)]}
|
||||
onValueChange={(v) => (columns = Number(v[0] ?? columns))}
|
||||
aria-label="columnas"
|
||||
>
|
||||
<ToggleGroup.Item value="0">fluido</ToggleGroup.Item>
|
||||
{#each [2, 3, 4] as value (value)}
|
||||
<ToggleGroup.Item value={String(value)}>{value}</ToggleGroup.Item>
|
||||
{/each}
|
||||
</ToggleGroup>
|
||||
</Group>
|
||||
|
||||
<Group gap={2} align="center" justify="start">
|
||||
<Text size="sm" color="muted">count-up</Text>
|
||||
<ToggleGroup
|
||||
selectionMode="single"
|
||||
size="sm"
|
||||
attached
|
||||
value={[withCount ? 'sí' : 'no']}
|
||||
onValueChange={(v) => (withCount = (v[0] ?? 'sí') === 'sí')}
|
||||
aria-label="contar las cifras"
|
||||
>
|
||||
<ToggleGroup.Item value="sí">sí</ToggleGroup.Item>
|
||||
<ToggleGroup.Item value="no">no</ToggleGroup.Item>
|
||||
</ToggleGroup>
|
||||
</Group>
|
||||
|
||||
<Group gap={2} align="center" justify="start">
|
||||
<Text size="sm" color="muted">dir</Text>
|
||||
<ToggleGroup
|
||||
selectionMode="single"
|
||||
size="sm"
|
||||
attached
|
||||
value={[dir]}
|
||||
onValueChange={(v) => (dir = (v[0] ?? dir) as typeof dir)}
|
||||
aria-label="dirección (solo la vista previa)"
|
||||
>
|
||||
<ToggleGroup.Item value="ltr">ltr</ToggleGroup.Item>
|
||||
<ToggleGroup.Item value="rtl">rtl</ToggleGroup.Item>
|
||||
</ToggleGroup>
|
||||
</Group>
|
||||
</Wrap>
|
||||
{/snippet}
|
||||
|
||||
{#snippet composition()}
|
||||
<Stack gap={4}>
|
||||
<Text color="muted">
|
||||
El block coloca y escalona; la semántica de KPI es del <Code>Metrics</Code> del canon y el
|
||||
conteo, del <Code>CountUp</Code>. Cifras y palabras son de la app.
|
||||
</Text>
|
||||
<Stack gap={3}>
|
||||
<DocRow term="raíz">
|
||||
<Code><section></Code> + <Code>Section</Code> + <Code>Container</Code> +
|
||||
<Code>AutoGrid</Code> con <Code>data-stagger</Code>: fluido por
|
||||
<Code>minChildWidth</Code> o con <Code>columns</Code> fijas.
|
||||
</DocRow>
|
||||
<DocRow term=".Stat">
|
||||
<Code>Motion trigger="viewport"</Code> envolviendo un <Code>Metrics</Code>. ES el reveal,
|
||||
así que debe seguir siendo hijo directo de la banda para recibir su índice estructural.
|
||||
</DocRow>
|
||||
<DocRow term=".Value">
|
||||
<Code>Metrics.Value</Code>; con <Code>count</Code> compone <Code>CountUp</Code>. El
|
||||
formato es locale-aware y reduced-motion salta al valor final — ambas cosas del
|
||||
<Code>CountUp</Code>, no del block.
|
||||
</DocRow>
|
||||
<DocRow term=".Label"><Code>Metrics.Label</Code>. Las palabras, de la app.</DocRow>
|
||||
</Stack>
|
||||
</Stack>
|
||||
{/snippet}
|
||||
|
||||
{#snippet api()}
|
||||
<Stack gap={3}>
|
||||
<DocRow term="StatsBand">
|
||||
<Code>container</Code> (def. <Code>lg</Code>) · <Code>size</Code> (def. <Code>lg</Code>) ·
|
||||
<Code>columns</Code> o <Code>minChildWidth</Code> (def. <Code>12rem</Code>) ·
|
||||
<Code>gap</Code> (def. <Code>8</Code>).
|
||||
</DocRow>
|
||||
<DocRow term=".Stat">
|
||||
Todo lo de <Code>Metrics</Code>: <Code>align</Code> (def. <Code>center</Code> — una banda
|
||||
son cifras centradas, no tarjetas de panel), <Code>size</Code> (def. <Code>lg</Code>),
|
||||
<Code>live</Code>…
|
||||
</DocRow>
|
||||
<DocRow term=".Value">
|
||||
<Code>count</Code> (number, opt-in) + <Code>countOptions</Code> (todo lo de
|
||||
<Code>CountUp</Code> menos <Code>to</Code>). Sin <Code>count</Code>, la cifra la pone la app
|
||||
por children.
|
||||
</DocRow>
|
||||
</Stack>
|
||||
{/snippet}
|
||||
|
||||
{#snippet a11y()}
|
||||
<Stack gap={3}>
|
||||
<DocRow term="Movimiento">
|
||||
El conteo respeta <Code>prefers-reduced-motion</Code>: salta directo al valor final, sin
|
||||
animar. Lo resuelve el <Code>CountUp</Code>, no el block.
|
||||
</DocRow>
|
||||
<DocRow term="Cifras">
|
||||
El formato numérico sale de <Code>uix.format</Code> (separadores por idioma), así que la
|
||||
cifra se lee correcta en cada locale sin que la app la reescriba.
|
||||
</DocRow>
|
||||
<DocRow term="Estructura">
|
||||
Sin encabezados: una banda son datos, no una sección con título. Si la página necesita uno,
|
||||
lo pone alrededor.
|
||||
</DocRow>
|
||||
</Stack>
|
||||
{/snippet}
|
||||
|
||||
{#snippet gaps()}
|
||||
<Stack gap={3}>
|
||||
<DocRow term="Split con imagen">
|
||||
<strong>Diferido</strong> — la banda junto a una foto: otra disposición (2 columnas), que
|
||||
recurre en las refs. Entra como <Code>layout</Code> o hermano cuando una demo lo pida.
|
||||
</DocRow>
|
||||
<DocRow term="Timeline / stepped">
|
||||
<strong>Diferido</strong> — el <Code>Timeline</Code> del canon existe; es otra sección, no
|
||||
una banda.
|
||||
</DocRow>
|
||||
<DocRow term="Delta / tendencia por cifra">
|
||||
<strong>App-land</strong> — <Code>Metrics.Delta</Code> se compone dentro del
|
||||
<Code>.Stat</Code> hoy; re-exponerlo solo añadiría superficie.
|
||||
</DocRow>
|
||||
</Stack>
|
||||
{/snippet}
|
||||
|
||||
{#snippet notes()}
|
||||
<Stack gap={3}>
|
||||
<DocRow term="El count-up es opt-in, nunca por defecto">
|
||||
Una cifra que se anima sin que el lector lo pida es ruido, y algunas no son contables
|
||||
(«99,98 %»). Por eso <Code>count</Code> se pide explícitamente.
|
||||
</DocRow>
|
||||
<DocRow term="El block no formatea ni escribe">
|
||||
Ni una palabra ni un separador decimal salen de aquí: las palabras por children, el formato
|
||||
por <Code>uix.format</Code> dentro del <Code>CountUp</Code>.
|
||||
</DocRow>
|
||||
</Stack>
|
||||
{/snippet}
|
||||
</BlockDemo>
|
||||
@ -0,0 +1,57 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* The stats-band mini-page: the band with real product figures, plus enough
|
||||
* page around it to read as a page — so the demo can render it FULL-BLEED and
|
||||
* the `preview` route can serve it as a standalone document.
|
||||
*/
|
||||
import { StatsBand } from '$blocks/stats-band';
|
||||
import { Section } from '$uix/eidos/components/section';
|
||||
import { Container } from '$uix/eidos/components/container';
|
||||
import { Stack } from '$uix/eidos/components/stack';
|
||||
import { Heading } from '$uix/eidos/components/heading';
|
||||
import { Text } from '$uix/eidos/components/text';
|
||||
|
||||
let {
|
||||
columns = 0,
|
||||
withCount = true
|
||||
}: {
|
||||
/** 0 = fluid (minChildWidth); otherwise a fixed column count. */
|
||||
columns?: number;
|
||||
withCount?: boolean;
|
||||
} = $props();
|
||||
|
||||
// `count` drives CountUp; `display` is what the app renders when it does not
|
||||
// count (a figure like "99,98 %" is not a countable integer).
|
||||
const stats = [
|
||||
{ count: 12500, display: '12.500', label: 'Eventos por segundo' },
|
||||
{ count: 340, display: '340', label: 'Equipos de producto' },
|
||||
{ count: undefined, display: '99,98 %', label: 'Disponibilidad medida' },
|
||||
{ count: 48, display: '48', label: 'Integraciones nativas' }
|
||||
];
|
||||
</script>
|
||||
|
||||
<StatsBand columns={columns || undefined}>
|
||||
{#each stats as stat (stat.label)}
|
||||
<StatsBand.Stat>
|
||||
{#if withCount && stat.count !== undefined}
|
||||
<StatsBand.Value count={stat.count} />
|
||||
{:else}
|
||||
<StatsBand.Value>{stat.display}</StatsBand.Value>
|
||||
{/if}
|
||||
<StatsBand.Label>{stat.label}</StatsBand.Label>
|
||||
</StatsBand.Stat>
|
||||
{/each}
|
||||
</StatsBand>
|
||||
|
||||
<!-- A floor so the page reads as a page. -->
|
||||
<Section size="lg">
|
||||
<Container size="lg">
|
||||
<Stack gap={4}>
|
||||
<Heading level={2}>Los números salen del mismo motor que el panel</Heading>
|
||||
<Text color="muted" measure="wide">
|
||||
No son cifras de folleto: se recalculan con los mismos agregados que consulta cualquier
|
||||
cliente, así que lo que ves aquí es lo que verías dentro.
|
||||
</Text>
|
||||
</Stack>
|
||||
</Container>
|
||||
</Section>
|
||||
@ -0,0 +1,30 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* The preview is its OWN page — `@` resets the layout. Axes arrive via the URL
|
||||
* so the demo's iframe can drive them.
|
||||
*/
|
||||
import { page } from '$app/state';
|
||||
import BootUix, { type BootLanguage } from '../../_lib/BootUix.svelte';
|
||||
import '@/uix/eidos/index.css';
|
||||
|
||||
let { children } = $props();
|
||||
|
||||
const mode = $derived(page.url.searchParams.get('mode') === 'dark' ? 'dark' : 'light');
|
||||
const dir = $derived(page.url.searchParams.get('dir') === 'rtl' ? 'rtl' : 'ltr');
|
||||
const language = $derived((page.url.searchParams.get('lang') ?? 'es') as BootLanguage);
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<meta name="color-scheme" content="light dark" />
|
||||
</svelte:head>
|
||||
|
||||
<BootUix {mode} {dir} {language}>
|
||||
<div
|
||||
data-theme={mode}
|
||||
data-mode={mode}
|
||||
{dir}
|
||||
style="min-block-size: 100dvh; background: var(--color-surface-default); color: var(--color-content-primary);"
|
||||
>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
</BootUix>
|
||||
@ -0,0 +1,14 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* Standalone page for the stats-band mini-site — same component the demo
|
||||
* renders inline, served as its OWN document for the device-width frame.
|
||||
*/
|
||||
import { page } from '$app/state';
|
||||
import StatsBandSite from '../StatsBandSite.svelte';
|
||||
|
||||
const params = $derived(page.url.searchParams);
|
||||
const columns = $derived(Number(params.get('columns') ?? 0) || 0);
|
||||
const withCount = $derived(params.get('count') !== 'false');
|
||||
</script>
|
||||
|
||||
<StatsBandSite {columns} {withCount} />
|
||||
Loading…
Reference in new issue