Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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.",
Expand Down
62 changes: 62 additions & 0 deletions src/components/DigitPopIn/DigitPopIn.css
Original file line number Diff line number Diff line change
@@ -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;
}
}
76 changes: 76 additions & 0 deletions src/components/DigitPopIn/DigitPopIn.test.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLElement>(".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(<DigitPopIn text="1,234" />);
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(<DigitPopIn text="1,234" />);
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(<DigitPopIn text="12 ms" />);
expect(digits(container)[2]?.textContent).toBe(" ");
});

it("remounts the characters when the text changes, so they play again", () => {
const { container, rerender } = render(<DigitPopIn text="12" />);
const before = container.querySelector(".digit-pop-in__digit");
rerender(<DigitPopIn text="13" />);
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(<DigitPopIn text="12" />);
const before = container.querySelector(".digit-pop-in__digit");
rerender(<DigitPopIn text="12" className="x" />);
expect(container.querySelector(".digit-pop-in__digit")).toBe(before);
});

it("renders plain text under reduced motion", () => {
const restore = mockReducedMotion();
try {
const { container } = render(<DigitPopIn text="1,234" />);
expect(container.querySelector(".digit-pop-in__digit")).toBeNull();
expect(screen.getByText("1,234")).toBeInTheDocument();
} finally {
restore();
}
});
});
55 changes: 55 additions & 0 deletions src/components/DigitPopIn/DigitPopIn.tsx
Original file line number Diff line number Diff line change
@@ -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 <span className={rootClass}>{text}</span>;
}

return (
<span className={rootClass}>
<span className="digit-pop-in__text">{text}</span>
{/* Keyed on the text, so a new value remounts the characters and the
animation plays again. */}
<span key={text} className="digit-pop-in__digits" aria-hidden="true">
{Array.from(text).map((char, index) => (
<span
// Position is the identity: the list is rebuilt whole whenever
// the text changes.
key={index}
className="digit-pop-in__digit"
style={{ "--digit-pop-in-index": index } as CSSProperties}
>
{char === " " ? " " : char}
</span>
))}
</span>
</span>
);
}
2 changes: 2 additions & 0 deletions src/components/DigitPopIn/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { DigitPopIn } from "./DigitPopIn";
export type { DigitPopInProps } from "./DigitPopIn";
8 changes: 8 additions & 0 deletions src/components/StatCard/StatCard.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
37 changes: 37 additions & 0 deletions src/components/StatCard/StatCard.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<StatCard
label="Rate"
value={98.5}
format={(v) => `${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(
<StatCard label="State" value="Idle" animate="digits" />,
);
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(<StatCard label="Calls" value={4200} animate />);
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,
Expand Down
39 changes: 31 additions & 8 deletions src/components/StatCard/StatCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -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;
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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 ? (
<DigitPopIn text={displayValue} />
) : (
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.
Expand Down Expand Up @@ -218,7 +241,7 @@ function StatCardComponent({
// the exact DOM they had before the slot existed.
<span className="stat-card__value-line">
<span className="stat-card__value-row">
<span className="stat-card__value">{displayValue}</span>
<span className={valueClass}>{valueContent}</span>
{valueSuffix && (
<span className="stat-card__value-suffix">{valueSuffix}</span>
)}
Expand All @@ -227,7 +250,7 @@ function StatCardComponent({
</span>
) : (
<span className="stat-card__value-row">
<span className="stat-card__value">{displayValue}</span>
<span className={valueClass}>{valueContent}</span>
{valueSuffix && (
<span className="stat-card__value-suffix">{valueSuffix}</span>
)}
Expand Down
1 change: 1 addition & 0 deletions src/components/StatCard/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ export { StatCard } from "./StatCard";
export { formatCompactNumber } from "./formatters";
export type {
StatCardProps,
StatCardAnimation,
StatCardEmphasis,
StatCardTone,
StatCardTrend,
Expand Down
Loading
Loading