diff --git a/CHANGELOG.md b/CHANGELOG.md index 0a97763..3acb7de 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,31 @@ Versioning follows the policy in [CONTRIBUTING.md](CONTRIBUTING.md#versioning). ## [Unreleased] +## [0.1.0-alpha.23] + +### Added + +- **`DigitPopIn`, text that arrives one character at a time.** Each character + of a formatted number rises into place from a light blur after the one + before it, and a new `text` plays it again. The characters are hidden from + assistive tech and a plain copy of the text is read instead, so a screen + reader hears "1,234" rather than five glyphs. Under `prefers-reduced-motion` + it renders the plain text. Duration, distance, stagger, blur and easing are + custom properties on the root, so one use can retune the motion without a + new prop. + + It is exported rather than kept inside `StatCard` because the numbers that + want it are not all in cards: the Statistics page in `backend.ai-go` shows + latency percentiles and cowork metrics beside its stat cards, and is the + consumer taking it up in both places. + +- **`StatCard` takes `animate="digits"`.** `animate` now also accepts + `"count"` and `"digits"`; `true` still means the count-up it always did, so + no existing call changes. `"digits"` renders the formatted value through + `DigitPopIn`, and the value then clips only sideways, since a vertical clip + would cut the characters off as they rise. String values and the loading + state render as before. + ## [0.1.0-alpha.22] ### Fixed diff --git a/package.json b/package.json index 7baeea4..4ae54fb 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@lablup/ui-common", - "version": "0.1.0-alpha.22", + "version": "0.1.0-alpha.23", "description": "Shared, product-neutral UI components and design tokens for Lablup products", "license": "Apache-2.0", "author": "Lablup Inc.", diff --git a/src/components/DigitPopIn/DigitPopIn.css b/src/components/DigitPopIn/DigitPopIn.css new file mode 100644 index 0000000..57b467e --- /dev/null +++ b/src/components/DigitPopIn/DigitPopIn.css @@ -0,0 +1,62 @@ +/** + * DigitPopIn styles + * + * Each character rises into place from a light blur, one after another. The + * custom properties on the root tune the motion for one use without a new + * prop. + */ + +.digit-pop-in { + --digit-pop-in-duration: 500ms; + --digit-pop-in-distance: 8px; + --digit-pop-in-stagger: 70ms; + --digit-pop-in-blur: 2px; + --digit-pop-in-ease: cubic-bezier(0.34, 1.45, 0.64, 1); + display: inline-block; + position: relative; +} + +.digit-pop-in__digits { + display: inline-flex; + align-items: baseline; +} + +.digit-pop-in__digit { + display: inline-block; + animation: digit-pop-in var(--digit-pop-in-duration) var(--digit-pop-in-ease) both; + animation-delay: calc(var(--digit-pop-in-index, 0) * var(--digit-pop-in-stagger)); +} + +/* The text a screen reader gets in place of the per-character copy. */ +.digit-pop-in__text { + position: absolute; + width: 1px; + height: 1px; + margin: -1px; + padding: 0; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; + border: 0; +} + +@keyframes digit-pop-in { + from { + transform: translateY(var(--digit-pop-in-distance)); + opacity: 0; + filter: blur(var(--digit-pop-in-blur)); + } + to { + transform: translateY(0); + opacity: 1; + filter: blur(0); + } +} + +/* The component renders plain text under reduced motion; this covers a + preference that changes while characters are on screen. */ +@media (prefers-reduced-motion: reduce) { + .digit-pop-in__digit { + animation: none; + } +} diff --git a/src/components/DigitPopIn/DigitPopIn.test.tsx b/src/components/DigitPopIn/DigitPopIn.test.tsx new file mode 100644 index 0000000..989c08b --- /dev/null +++ b/src/components/DigitPopIn/DigitPopIn.test.tsx @@ -0,0 +1,76 @@ +import { describe, expect, it, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import "@testing-library/jest-dom/vitest"; + +import { DigitPopIn } from "./DigitPopIn"; + +function digits(container: HTMLElement): HTMLElement[] { + return Array.from(container.querySelectorAll(".digit-pop-in__digit")); +} + +function mockReducedMotion(): () => void { + const original = window.matchMedia; + window.matchMedia = ((query: string) => + ({ + matches: query.includes("prefers-reduced-motion"), + media: query, + onchange: null, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), + dispatchEvent: vi.fn(), + }) as unknown as MediaQueryList) as typeof window.matchMedia; + return () => { + window.matchMedia = original; + }; +} + +describe("DigitPopIn", () => { + it("renders one staggered element per character", () => { + const { container } = render(); + const chars = digits(container); + expect(chars.map((char) => char.textContent)).toEqual(["1", ",", "2", "3", "4"]); + expect(chars[4]?.style.getPropertyValue("--digit-pop-in-index")).toBe("4"); + }); + + it("hides the characters from assistive tech and exposes the whole text", () => { + const { container } = render(); + expect(container.querySelector(".digit-pop-in__digits")).toHaveAttribute( + "aria-hidden", + "true", + ); + expect(container.querySelector(".digit-pop-in__text")).toHaveTextContent("1,234"); + }); + + it("keeps a space as a non-breaking character so it holds its width", () => { + const { container } = render(); + expect(digits(container)[2]?.textContent).toBe(" "); + }); + + it("remounts the characters when the text changes, so they play again", () => { + const { container, rerender } = render(); + const before = container.querySelector(".digit-pop-in__digit"); + rerender(); + expect(digits(container).map((char) => char.textContent)).toEqual(["1", "3"]); + expect(container.querySelector(".digit-pop-in__digit")).not.toBe(before); + }); + + it("keeps the same characters when the text does not change", () => { + const { container, rerender } = render(); + const before = container.querySelector(".digit-pop-in__digit"); + rerender(); + expect(container.querySelector(".digit-pop-in__digit")).toBe(before); + }); + + it("renders plain text under reduced motion", () => { + const restore = mockReducedMotion(); + try { + const { container } = render(); + expect(container.querySelector(".digit-pop-in__digit")).toBeNull(); + expect(screen.getByText("1,234")).toBeInTheDocument(); + } finally { + restore(); + } + }); +}); diff --git a/src/components/DigitPopIn/DigitPopIn.tsx b/src/components/DigitPopIn/DigitPopIn.tsx new file mode 100644 index 0000000..d777324 --- /dev/null +++ b/src/components/DigitPopIn/DigitPopIn.tsx @@ -0,0 +1,55 @@ +/** + * DigitPopIn Component + * + * Renders text, usually a formatted number, one element per character, each + * rising into place from a light blur after the one before it. A new `text` + * replays the animation. Under `prefers-reduced-motion` it renders the plain + * text and never animates. + * + * The characters are hidden from assistive tech and a plain copy of the text + * is read instead, so a screen reader hears "1,234" rather than five glyphs. + * + * `StatCard` uses it for `animate="digits"`; use it directly for a number + * that is not in a card. + */ + +import type { CSSProperties, JSX } from "react"; +import { usePrefersReducedMotion } from "../../hooks/usePrefersReducedMotion"; +import "./DigitPopIn.css"; + +export interface DigitPopInProps { + /** The text to animate, already formatted ("1,234", "98.5%"). */ + text: string; + /** Extra class name applied to the root. */ + className?: string; +} + +export function DigitPopIn({ text, className = "" }: DigitPopInProps): JSX.Element { + const prefersReducedMotion = usePrefersReducedMotion(); + const rootClass = ["digit-pop-in", className].filter(Boolean).join(" "); + + if (prefersReducedMotion) { + return {text}; + } + + return ( + + {text} + {/* Keyed on the text, so a new value remounts the characters and the + animation plays again. */} + + + ); +} diff --git a/src/components/DigitPopIn/index.ts b/src/components/DigitPopIn/index.ts new file mode 100644 index 0000000..b408ba7 --- /dev/null +++ b/src/components/DigitPopIn/index.ts @@ -0,0 +1,2 @@ +export { DigitPopIn } from "./DigitPopIn"; +export type { DigitPopInProps } from "./DigitPopIn"; diff --git a/src/components/StatCard/StatCard.css b/src/components/StatCard/StatCard.css index 020f366..69ab40f 100644 --- a/src/components/StatCard/StatCard.css +++ b/src/components/StatCard/StatCard.css @@ -98,6 +98,14 @@ white-space: nowrap; } +/* `animate="digits"`: the characters rise into place from below the line + (DigitPopIn), so the value clips only sideways here. A vertical clip would + cut them off as they come up. */ +.stat-card__value--digits { + overflow-x: clip; + overflow-y: visible; +} + .stat-card__value-suffix { font-size: var(--token-fontSize, 0.875rem); font-weight: 400; diff --git a/src/components/StatCard/StatCard.test.tsx b/src/components/StatCard/StatCard.test.tsx index 0efe5b2..e955a69 100644 --- a/src/components/StatCard/StatCard.test.tsx +++ b/src/components/StatCard/StatCard.test.tsx @@ -238,6 +238,43 @@ describe("StatCard", () => { expect(screen.getByLabelText("Calls: 4,200")).toBeInTheDocument(); }); }); + describe('animate="digits"', () => { + function digits(container: HTMLElement): string[] { + return Array.from(container.querySelectorAll(".digit-pop-in__digit")).map( + (digit) => digit.textContent ?? "", + ); + } + + it("pops the formatted value in, one element per character", () => { + const { container } = render( + `${v.toFixed(1)}%`} + animate="digits" + />, + ); + expect(digits(container)).toEqual(["9", "8", ".", "5", "%"]); + expect(container.querySelector(".stat-card__value")).toHaveClass( + "stat-card__value--digits", + ); + expect(screen.getByLabelText("Rate: 98.5%")).toBeInTheDocument(); + }); + + it("renders plain text for a string value", () => { + const { container } = render( + , + ); + expect(container.querySelector(".digit-pop-in")).toBeNull(); + expect(screen.getByText("Idle")).toBeInTheDocument(); + }); + + it("keeps `animate` as the count-up it always was", () => { + const { container } = render(); + expect(container.querySelector(".digit-pop-in")).toBeNull(); + }); + }); + /** * A stat label is not always plain text. The case this exists for is a * glossary term: the label carries its own definition on hover and focus, diff --git a/src/components/StatCard/StatCard.tsx b/src/components/StatCard/StatCard.tsx index 9366fd5..1ba35ce 100644 --- a/src/components/StatCard/StatCard.tsx +++ b/src/components/StatCard/StatCard.tsx @@ -14,6 +14,7 @@ import { memo, useEffect, useRef, useState, type ReactNode, type JSX } from "react"; import { usePrefersReducedMotion } from "../../hooks/usePrefersReducedMotion"; import { BaseCard } from "../BaseCard"; +import { DigitPopIn } from "../DigitPopIn"; import { Skeleton } from "../Skeleton"; import "./StatCard.css"; @@ -23,6 +24,13 @@ export type StatCardTone = "default" | "success" | "warning" | "danger" | "info" export type StatCardTrendDirection = "up" | "down" | "flat"; +/** + * How a numeric value arrives. `count` counts up from the previous value; + * `digits` pops each character of the formatted value in, staggered, and + * replays when the value changes. + */ +export type StatCardAnimation = "count" | "digits"; + export interface StatCardTrend { /** Direction indicator (up / down / flat) */ direction: StatCardTrendDirection; @@ -72,11 +80,14 @@ export interface StatCardProps { */ format?: (value: number) => string; /** - * Count up to a numeric value on mount and on change. Off by default so + * Animate a numeric value on mount and on change. `true` or `"count"` + * counts up to it; `"digits"` pops each character of the formatted value + * in, staggered, rising into place from a light blur. Off by default so * existing consumers render the final value immediately; opt in for - * dashboard hero metrics. Suppressed under `prefers-reduced-motion`. + * dashboard metrics. Suppressed under `prefers-reduced-motion`, and ignored + * for string values and while loading. */ - animate?: boolean; + animate?: boolean | StatCardAnimation; /** * Optional trailing visual on the value line (e.g. a sparkline). Kept as a * slot so the card does not depend on any particular chart component. @@ -169,13 +180,25 @@ function StatCardComponent({ }: StatCardProps): JSX.Element { const prefersReducedMotion = usePrefersReducedMotion(); const isNumeric = typeof value === "number"; - const shouldAnimate = animate && isNumeric && !loading && !prefersReducedMotion; - const animatedValue = useAnimatedValue(isNumeric ? value : 0, shouldAnimate); + const animation: StatCardAnimation | null = + animate === true ? "count" : animate === false ? null : animate; + const canAnimate = isNumeric && !loading && !prefersReducedMotion; + const shouldCount = animation === "count" && canAnimate; + const shouldPopDigits = animation === "digits" && canAnimate; + const animatedValue = useAnimatedValue(isNumeric ? value : 0, shouldCount); const displayValue = formatValue( - isNumeric && shouldAnimate ? animatedValue : value, + isNumeric && shouldCount ? animatedValue : value, format, ); + const valueContent = shouldPopDigits ? ( + + ) : ( + displayValue + ); + const valueClass = shouldPopDigits + ? "stat-card__value stat-card__value--digits" + : "stat-card__value"; // The aria-label always describes the settled value, never an in-flight // animation frame, so assistive tech is not read a counting-up number. @@ -218,7 +241,7 @@ function StatCardComponent({ // the exact DOM they had before the slot existed. - {displayValue} + {valueContent} {valueSuffix && ( {valueSuffix} )} @@ -227,7 +250,7 @@ function StatCardComponent({ ) : ( - {displayValue} + {valueContent} {valueSuffix && ( {valueSuffix} )} diff --git a/src/components/StatCard/index.ts b/src/components/StatCard/index.ts index f766c89..97e3e93 100644 --- a/src/components/StatCard/index.ts +++ b/src/components/StatCard/index.ts @@ -2,6 +2,7 @@ export { StatCard } from "./StatCard"; export { formatCompactNumber } from "./formatters"; export type { StatCardProps, + StatCardAnimation, StatCardEmphasis, StatCardTone, StatCardTrend, diff --git a/src/index.ts b/src/index.ts index 18d436c..a738320 100644 --- a/src/index.ts +++ b/src/index.ts @@ -17,6 +17,7 @@ export * from "./components/BaseCard"; export * from "./components/Badge"; export * from "./components/Button"; export * from "./components/DataTable"; +export * from "./components/DigitPopIn"; export * from "./components/Drawer"; export * from "./components/EmptyState"; export * from "./components/ErrorState";