From 4c8938214060e8fd1204e5058148e5516b57cc5b Mon Sep 17 00:00:00 2001 From: Lochie Axon Date: Sat, 7 Mar 2026 09:45:07 +1100 Subject: [PATCH 01/10] setup numbermorph --- packages/torph/src/index.ts | 5 +- .../torph/src/lib/number-morph/animate.ts | 120 +++++++++ .../torph/src/lib/number-morph/controller.ts | 45 ++++ packages/torph/src/lib/number-morph/index.ts | 239 ++++++++++++++++++ .../torph/src/lib/number-morph/segment.ts | 172 +++++++++++++ packages/torph/src/lib/number-morph/types.ts | 6 + packages/torph/src/lib/text-morph/index.ts | 40 +-- packages/torph/src/lib/text-morph/types.ts | 11 +- .../torph/src/lib/text-morph/utils/animate.ts | 84 +----- .../torph/src/lib/text-morph/utils/segment.ts | 6 +- packages/torph/src/lib/utils/animate.ts | 172 +++++++++++++ .../lib/{text-morph => }/utils/constants.ts | 0 .../src/lib/{text-morph => }/utils/dom.ts | 2 +- .../src/lib/{text-morph => }/utils/flip.ts | 0 .../{text-morph => }/utils/reduced-motion.ts | 0 .../src/lib/{text-morph => }/utils/spring.ts | 11 + .../src/lib/{text-morph => }/utils/styles.ts | 0 packages/torph/src/lib/utils/types.ts | 25 ++ packages/torph/src/react/NumberMorph.tsx | 76 ++++++ packages/torph/src/react/index.ts | 3 + 20 files changed, 892 insertions(+), 125 deletions(-) create mode 100644 packages/torph/src/lib/number-morph/animate.ts create mode 100644 packages/torph/src/lib/number-morph/controller.ts create mode 100644 packages/torph/src/lib/number-morph/index.ts create mode 100644 packages/torph/src/lib/number-morph/segment.ts create mode 100644 packages/torph/src/lib/number-morph/types.ts create mode 100644 packages/torph/src/lib/utils/animate.ts rename packages/torph/src/lib/{text-morph => }/utils/constants.ts (100%) rename packages/torph/src/lib/{text-morph => }/utils/dom.ts (98%) rename packages/torph/src/lib/{text-morph => }/utils/flip.ts (100%) rename packages/torph/src/lib/{text-morph => }/utils/reduced-motion.ts (100%) rename packages/torph/src/lib/{text-morph => }/utils/spring.ts (88%) rename packages/torph/src/lib/{text-morph => }/utils/styles.ts (100%) create mode 100644 packages/torph/src/lib/utils/types.ts create mode 100644 packages/torph/src/react/NumberMorph.tsx diff --git a/packages/torph/src/index.ts b/packages/torph/src/index.ts index 9980bca..a365279 100644 --- a/packages/torph/src/index.ts +++ b/packages/torph/src/index.ts @@ -1,3 +1,6 @@ export { DEFAULT_AS, DEFAULT_TEXT_MORPH_OPTIONS, MorphController, TextMorph } from "./lib/text-morph"; export type { TextMorphOptions } from "./lib/text-morph/types"; -export type { SpringParams } from "./lib/text-morph/utils/spring"; +export type { SpringParams } from "./lib/utils/spring"; + +export { DEFAULT_NUMBER_MORPH_OPTIONS, NumberMorph } from "./lib/number-morph"; +export type { NumberMorphOptions } from "./lib/number-morph/types"; diff --git a/packages/torph/src/lib/number-morph/animate.ts b/packages/torph/src/lib/number-morph/animate.ts new file mode 100644 index 0000000..4bed86b --- /dev/null +++ b/packages/torph/src/lib/number-morph/animate.ts @@ -0,0 +1,120 @@ +import { + parseTranslate, + cancelAnimations, + fadeDuration, +} from "../utils/animate"; + +export function animateNumberExit( + child: HTMLElement, + options: { + dx: number; + dy: number; + slideDistance: number; + duration: number; + ease: string; + }, +) { + const { dx, dy, slideDistance, duration, ease } = options; + + child.animate( + { + transform: `translate(${dx}px, ${dy + slideDistance}px)`, + offset: 1, + }, + { + duration, + easing: ease, + fill: "both", + }, + ); + + const fadeAnimation = child.animate( + { + opacity: 0, + offset: 1, + }, + { + duration: fadeDuration(duration, 0.25), + easing: "linear", + fill: "both", + }, + ); + + fadeAnimation.onfinish = () => child.remove(); +} + +export function animateNumberEnter( + child: HTMLElement, + options: { + deltaX: number; + deltaY: number; + slideDistance: number; + kind: "digit" | "symbol"; + duration: number; + ease: string; + }, +) { + const { deltaX, deltaY, slideDistance, kind, duration, ease } = options; + + const prev = cancelAnimations(child); + + const slideOffset = kind === "digit" ? -slideDistance : slideDistance; + const startX = deltaX + prev.tx; + const startY = deltaY + prev.ty + slideOffset; + + child.animate( + { + transform: `translate(${startX}px, ${startY}px)`, + offset: 0, + }, + { + duration, + easing: ease, + fill: "both", + }, + ); + + const startOpacity = prev.opacity >= 1 ? 0 : prev.opacity; + if (startOpacity < 1) { + child.animate( + [{ opacity: startOpacity }, { opacity: 1 }], + { + duration: fadeDuration(duration, 0.5), + easing: "linear", + fill: "both", + }, + ); + } +} + +export function animateNumberPersist( + child: HTMLElement, + options: { + deltaX: number; + deltaY: number; + duration: number; + ease: string; + }, +) { + const { deltaX, deltaY, duration, ease } = options; + + const { tx, ty } = parseTranslate(child); + child.getAnimations().forEach((a) => a.cancel()); + + const startX = deltaX + tx; + const startY = deltaY + ty; + + if (startX === 0 && startY === 0) return; + + child.animate( + { + transform: `translate(${startX}px, ${startY}px)`, + offset: 0, + }, + { + duration, + easing: ease, + fill: "both", + }, + ); +} diff --git a/packages/torph/src/lib/number-morph/controller.ts b/packages/torph/src/lib/number-morph/controller.ts new file mode 100644 index 0000000..10e2572 --- /dev/null +++ b/packages/torph/src/lib/number-morph/controller.ts @@ -0,0 +1,45 @@ +import { NumberMorph } from "./index"; +import type { NumberMorphOptions } from "./types"; + +export class NumberMorphController { + private instance: NumberMorph | null = null; + private lastValue: number | string = ""; + private lastCursorIndex?: number; + private configKey = ""; + + attach(element: HTMLElement, options: Omit) { + this.instance?.destroy(); + this.instance = new NumberMorph({ element, ...options }); + this.configKey = NumberMorphController.serializeConfig(options); + + if (this.lastValue !== "") { + this.instance.update(this.lastValue, this.lastCursorIndex); + } + } + + update(value: number | string, cursorIndex?: number) { + this.lastValue = value; + this.lastCursorIndex = cursorIndex; + this.instance?.update(value, cursorIndex); + } + + needsRecreate(options: Omit): boolean { + return NumberMorphController.serializeConfig(options) !== this.configKey; + } + + destroy() { + this.instance?.destroy(); + this.instance = null; + } + + static serializeConfig(options: Omit): string { + return JSON.stringify({ + ease: options.ease, + duration: options.duration, + locale: options.locale, + decimals: options.decimals, + disabled: options.disabled, + respectReducedMotion: options.respectReducedMotion, + }); + } +} diff --git a/packages/torph/src/lib/number-morph/index.ts b/packages/torph/src/lib/number-morph/index.ts new file mode 100644 index 0000000..26bc2b8 --- /dev/null +++ b/packages/torph/src/lib/number-morph/index.ts @@ -0,0 +1,239 @@ +import type { NumberMorphOptions } from "./types"; +import { type NumberSegment, segmentNumber } from "./segment"; +import { + animateNumberExit, + animateNumberEnter, + animateNumberPersist, +} from "./animate"; +import { resolveEase } from "../utils/spring"; +import { BASE_DEFAULTS } from "../utils/types"; +import { + type Measures, + measure, + computeDelta, + findNearestAnchor, + resolveExitingAnchors, +} from "../utils/flip"; +import { transitionContainerSize } from "../utils/animate"; +import { detachFromFlow, reconcileChildren } from "../utils/dom"; +import { addStyles, removeStyles } from "../utils/styles"; +import { + ATTR_ROOT, + ATTR_ID, + ATTR_EXITING, +} from "../utils/constants"; +import { + type ReducedMotionState, + createReducedMotionListener, +} from "../utils/reduced-motion"; + +export type { NumberMorphOptions } from "./types"; +export type { NumberSegment } from "./segment"; +export { NumberMorphController } from "./controller"; + +export const DEFAULT_NUMBER_MORPH_OPTIONS = { + ...BASE_DEFAULTS, +} as const satisfies Omit; + +export class NumberMorph { + private element: HTMLElement; + private duration: number; + private ease: string; + private locale: string; + private decimals?: number; + private disabled: boolean; + private onAnimationStart?: () => void; + private onAnimationComplete?: () => void; + + private currentValue = ""; + private prevMeasures: Measures = {}; + private currentMeasures: Measures = {}; + private currentSegments: NumberSegment[] = []; + private isInitialRender = true; + private reducedMotion: ReducedMotionState | null = null; + + constructor(options: NumberMorphOptions) { + const opts = { ...DEFAULT_NUMBER_MORPH_OPTIONS, ...options }; + const { ease, duration } = resolveEase(opts.ease, opts.duration!); + + this.element = opts.element; + this.duration = duration; + this.ease = ease; + this.locale = opts.locale!; + this.decimals = opts.decimals; + this.disabled = opts.disabled!; + this.onAnimationStart = opts.onAnimationStart; + this.onAnimationComplete = opts.onAnimationComplete; + + if (opts.respectReducedMotion) { + this.reducedMotion = createReducedMotionListener(); + } + + if (!this.isDisabled()) { + this.element.setAttribute(ATTR_ROOT, ""); + this.element.style.transitionDuration = `${this.duration}ms`; + this.element.style.transitionTimingFunction = this.ease; + this.element.style.overflow = "hidden"; + addStyles(); + } + } + + destroy() { + this.reducedMotion?.destroy(); + this.element.getAnimations().forEach((anim) => anim.cancel()); + this.element.removeAttribute(ATTR_ROOT); + removeStyles(); + } + + private isDisabled(): boolean { + return Boolean( + this.disabled || this.reducedMotion?.prefersReducedMotion, + ); + } + + update(value: number | string, cursorIndex?: number) { + const formatted = + typeof value === "number" + ? value.toLocaleString(this.locale, { + minimumFractionDigits: this.decimals, + maximumFractionDigits: this.decimals, + }) + : value; + + if (formatted === this.currentValue) return; + this.currentValue = formatted; + + if (this.isDisabled()) { + this.element.textContent = formatted; + return; + } + + if (!this.isInitialRender && this.onAnimationStart) { + this.onAnimationStart(); + } + + const segments = segmentNumber(formatted, this.currentSegments, cursorIndex); + this.animate(segments); + } + + private animate(segments: NumberSegment[]) { + const element = this.element; + const oldWidth = element.offsetWidth; + const oldHeight = element.offsetHeight; + const slideDistance = element.offsetHeight || 20; + + this.prevMeasures = measure(element); + const oldChildren = Array.from(element.children) as HTMLElement[]; + const newIds = new Set(segments.map((s) => s.id)); + + const exiting = oldChildren.filter( + (child) => + !newIds.has(child.getAttribute(ATTR_ID) as string) && + !child.hasAttribute(ATTR_EXITING), + ); + const exitingSet = new Set(exiting); + const oldIds = oldChildren.map( + (c) => c.getAttribute(ATTR_ID) as string, + ); + + const exitingAnchorId = resolveExitingAnchors( + oldChildren, + exitingSet, + oldIds, + newIds, + ); + + detachFromFlow(exiting); + reconcileChildren(element, oldChildren, newIds, segments); + + this.currentMeasures = measure(element); + this.currentSegments = segments; + + exiting.forEach((child) => { + if (this.isInitialRender) { + child.remove(); + return; + } + + const anchorId = exitingAnchorId.get(child); + const { dx, dy } = anchorId + ? computeDelta(this.currentMeasures, this.prevMeasures, anchorId) + : { dx: 0, dy: 0 }; + + animateNumberExit(child, { + dx, + dy, + slideDistance, + duration: this.duration, + ease: this.ease, + }); + }); + + if (this.isInitialRender) { + this.isInitialRender = false; + element.style.width = "auto"; + element.style.height = "auto"; + return; + } + + this.animateChildren(segments, slideDistance); + + transitionContainerSize( + element, + oldWidth, + oldHeight, + this.duration, + this.onAnimationComplete, + ); + } + + private animateChildren(segments: NumberSegment[], slideDistance: number) { + const segmentIds = segments.map((s) => s.id); + const persistentIds = new Set( + segmentIds.filter((id) => this.prevMeasures[id]), + ); + const kindMap = new Map(segments.map((s) => [s.id, s.kind])); + + const children = Array.from(this.element.children) as HTMLElement[]; + children.forEach((child, index) => { + if (child.hasAttribute(ATTR_EXITING)) return; + + const key = child.getAttribute(ATTR_ID) || `child-${index}`; + const isNew = !this.prevMeasures[key]; + + if (isNew) { + const anchorKey = findNearestAnchor( + segments.findIndex((s) => s.id === key), + segmentIds, + persistentIds, + ); + + const { dx: deltaX, dy: deltaY } = anchorKey + ? computeDelta(this.prevMeasures, this.currentMeasures, anchorKey) + : { dx: 0, dy: 0 }; + + animateNumberEnter(child, { + deltaX, + deltaY, + slideDistance, + kind: kindMap.get(key) ?? "digit", + duration: this.duration, + ease: this.ease, + }); + } else { + const { dx: deltaX, dy: deltaY } = computeDelta( + this.prevMeasures, + this.currentMeasures, + key, + ); + + animateNumberPersist(child, { + deltaX, + deltaY, + duration: this.duration, + ease: this.ease, + }); + } + }); + } +} diff --git a/packages/torph/src/lib/number-morph/segment.ts b/packages/torph/src/lib/number-morph/segment.ts new file mode 100644 index 0000000..0b4d12b --- /dev/null +++ b/packages/torph/src/lib/number-morph/segment.ts @@ -0,0 +1,172 @@ +export type NumberSegment = { + id: string; + string: string; + kind: "digit" | "symbol"; +}; + +let nextNewId = 0; + +function classifyKind(char: string): NumberSegment["kind"] { + return /[0-9]/.test(char) ? "digit" : "symbol"; +} + +/** + * Segments a string into per-character NumberSegments. + * + * When `cursorIndex` is provided, uses position-based matching: + * characters before the edit keep their old IDs by position, + * characters after the edit keep theirs offset by the length change. + * + * When `cursorIndex` is not provided, falls back to greedy forward + * matching (works well for distinct characters). + */ +export function segmentNumber( + value: string, + prevSegments?: NumberSegment[], + cursorIndex?: number, +): NumberSegment[] { + const chars = value.split(""); + + if (!prevSegments || prevSegments.length === 0) { + return simpleSegment(chars); + } + + const oldChars = prevSegments.map((s) => + s.string === "\u00A0" ? " " : s.string, + ); + + const matches = + cursorIndex != null + ? cursorMatch(oldChars, chars, cursorIndex) + : greedyMatch(oldChars, chars); + + const usedIds = new Set(); + for (const [, oldIdx] of matches) { + usedIds.add(prevSegments[oldIdx]!.id); + } + + const result: NumberSegment[] = []; + + for (let i = 0; i < chars.length; i++) { + const char = chars[i]!; + const kind = classifyKind(char); + const displayChar = char === " " ? "\u00A0" : char; + + if (matches.has(i)) { + const oldIdx = matches.get(i)!; + result.push({ + id: prevSegments[oldIdx]!.id, + string: displayChar, + kind, + }); + } else { + let id = `${char}_n${nextNewId++}`; + while (usedIds.has(id)) { + id = `${char}_n${nextNewId++}`; + } + usedIds.add(id); + result.push({ id, string: displayChar, kind }); + } + } + + return result; +} + +/** Occurrence-based segmentation for initial render. */ +function simpleSegment(chars: string[]): NumberSegment[] { + const counts = new Map(); + + return chars.map((char) => { + const kind = classifyKind(char); + const count = counts.get(char) ?? 0; + counts.set(char, count + 1); + + if (char === " ") { + return { + id: count > 0 ? `space_${count}` : "space", + string: "\u00A0", + kind, + }; + } + + return { + id: count > 0 ? `${char}_${count}` : char, + string: char, + kind, + }; + }); +} + +/** + * Position-based matching using cursor position. + * The cursor in the NEW string tells us where the edit happened: + * - Insertion: chars were added just before cursor. Prefix [0, cursor-inserted) + * maps 1:1, suffix [cursor, end) maps to old [cursor-inserted, end). + * - Deletion: chars were removed. Prefix [0, cursor) maps 1:1, + * suffix [cursor, end) maps to old [cursor+deleted, end). + */ +function cursorMatch( + oldChars: string[], + newChars: string[], + cursor: number, +): Map { + const matches = new Map(); + const lenDiff = newChars.length - oldChars.length; + + if (lenDiff > 0) { + const editStart = cursor - lenDiff; + for (let i = 0; i < editStart && i < oldChars.length; i++) { + matches.set(i, i); + } + for (let i = cursor; i < newChars.length; i++) { + const oldIdx = i - lenDiff; + if (oldIdx >= 0 && oldIdx < oldChars.length) { + matches.set(i, oldIdx); + } + } + } else if (lenDiff < 0) { + for (let i = 0; i < cursor && i < newChars.length; i++) { + matches.set(i, i); + } + for (let i = cursor; i < newChars.length; i++) { + const oldIdx = i - lenDiff; + if (oldIdx >= 0 && oldIdx < oldChars.length) { + matches.set(i, oldIdx); + } + } + } else { + for (let i = 0; i < newChars.length; i++) { + if (newChars[i] === oldChars[i]) { + matches.set(i, i); + } + } + } + + return matches; +} + +/** + * Greedy forward matching: matches each old character to the earliest + * available position in the new string. Fallback when no cursor info. + * + * Returns a Map of newIndex → oldIndex for matched characters. + */ +function greedyMatch( + oldChars: string[], + newChars: string[], +): Map { + const matches = new Map(); + let newStart = 0; + + for (let i = 0; i < oldChars.length; i++) { + for (let j = newStart; j < newChars.length; j++) { + if (oldChars[i] === newChars[j]) { + matches.set(j, i); + newStart = j + 1; + break; + } + } + } + + return matches; +} diff --git a/packages/torph/src/lib/number-morph/types.ts b/packages/torph/src/lib/number-morph/types.ts new file mode 100644 index 0000000..5a0c91b --- /dev/null +++ b/packages/torph/src/lib/number-morph/types.ts @@ -0,0 +1,6 @@ +import type { BaseMorphOptions } from "../utils/types"; + +export interface NumberMorphOptions extends BaseMorphOptions { + locale?: string; + decimals?: number; +} diff --git a/packages/torph/src/lib/text-morph/index.ts b/packages/torph/src/lib/text-morph/index.ts index 51a6575..68b69c7 100644 --- a/packages/torph/src/lib/text-morph/index.ts +++ b/packages/torph/src/lib/text-morph/index.ts @@ -1,44 +1,40 @@ import type { TextMorphOptions } from "./types"; -import { spring as resolveSpring } from "./utils/spring"; -import { type Segment, segmentText } from "./utils/segment"; +import { BASE_DEFAULTS, type Segment } from "../utils/types"; +import { resolveEase } from "../utils/spring"; +import { segmentText } from "./utils/segment"; import { type Measures, measure, computeDelta, findNearestAnchor, resolveExitingAnchors, -} from "./utils/flip"; +} from "../utils/flip"; import { - animateExit, - animateEnterOrPersist, transitionContainerSize, -} from "./utils/animate"; -import { detachFromFlow, reconcileChildren } from "./utils/dom"; -import { addStyles, removeStyles } from "./utils/styles"; +} from "../utils/animate"; +import { animateExit, animateEnterOrPersist } from "./utils/animate"; +import { detachFromFlow, reconcileChildren } from "../utils/dom"; +import { addStyles, removeStyles } from "../utils/styles"; import { ATTR_ROOT, ATTR_DEBUG, ATTR_EXITING, ATTR_ID, -} from "./utils/constants"; +} from "../utils/constants"; import { type ReducedMotionState, createReducedMotionListener, -} from "./utils/reduced-motion"; +} from "../utils/reduced-motion"; export type { TextMorphOptions } from "./types"; -export type { SpringParams } from "./utils/spring"; +export type { SpringParams } from "../utils/spring"; export { MorphController } from "./controller"; export const DEFAULT_AS = "span"; export const DEFAULT_TEXT_MORPH_OPTIONS = { + ...BASE_DEFAULTS, debug: false, - locale: "en", - duration: 400, scale: true, - ease: "cubic-bezier(0.19, 1, 0.22, 1)", - disabled: false, - respectReducedMotion: true, } as const satisfies Omit; export class TextMorph { @@ -55,17 +51,7 @@ export class TextMorph { constructor(options: TextMorphOptions) { const { ease: rawEase, ...rest } = { ...DEFAULT_TEXT_MORPH_OPTIONS, ...options }; - let ease: string; - let duration: number; - - if (typeof rawEase === "object") { - const resolved = resolveSpring(rawEase); - ease = resolved.easing; - duration = resolved.duration; - } else { - ease = rawEase; - duration = rest.duration!; - } + const { ease, duration } = resolveEase(rawEase, rest.duration!); this.options = { ...rest, ease, duration }; diff --git a/packages/torph/src/lib/text-morph/types.ts b/packages/torph/src/lib/text-morph/types.ts index 14e2e65..40576a6 100644 --- a/packages/torph/src/lib/text-morph/types.ts +++ b/packages/torph/src/lib/text-morph/types.ts @@ -1,14 +1,7 @@ -import type { SpringParams } from "./utils/spring"; +import type { BaseMorphOptions } from "../utils/types"; -export interface TextMorphOptions { +export interface TextMorphOptions extends BaseMorphOptions { debug?: boolean; - element: HTMLElement; locale?: Intl.LocalesArgument; scale?: boolean; - duration?: number; // in ms - ease?: string | SpringParams; - disabled?: boolean; - respectReducedMotion?: boolean; - onAnimationStart?: () => void; - onAnimationComplete?: () => void; } diff --git a/packages/torph/src/lib/text-morph/utils/animate.ts b/packages/torph/src/lib/text-morph/utils/animate.ts index 16fb0b6..3e0cb0f 100644 --- a/packages/torph/src/lib/text-morph/utils/animate.ts +++ b/packages/torph/src/lib/text-morph/utils/animate.ts @@ -1,31 +1,4 @@ -const MAX_FADE_DURATION = 150; - -function fadeDuration(duration: number, fraction: number): number { - return Math.min(duration * fraction, MAX_FADE_DURATION); -} - -export function parseTranslate(element: HTMLElement): { - tx: number; - ty: number; -} { - const transform = getComputedStyle(element).transform; - if (!transform || transform === "none") return { tx: 0, ty: 0 }; - const match = transform.match(/matrix\(([^)]+)\)/); - if (!match) return { tx: 0, ty: 0 }; - const v = match[1]!.split(",").map(Number); - return { tx: v[4] || 0, ty: v[5] || 0 }; -} - -function cancelAnimations(element: HTMLElement): { - tx: number; - ty: number; - opacity: number; -} { - const { tx, ty } = parseTranslate(element); - const opacity = Number(getComputedStyle(element).opacity) || 1; - element.getAnimations().forEach((a) => a.cancel()); - return { tx, ty, opacity }; -} +import { cancelAnimations, fadeDuration } from "../../utils/animate"; export function animateExit( child: HTMLElement, @@ -109,58 +82,3 @@ export function animateEnterOrPersist( ); } } - -let pendingCleanup: (() => void) | null = null; - -export function transitionContainerSize( - element: HTMLElement, - oldWidth: number, - oldHeight: number, - duration: number, - onComplete?: () => void, -) { - // Cancel any pending cleanup from a previous transition - if (pendingCleanup) { - pendingCleanup(); - pendingCleanup = null; - } - - if (oldWidth === 0 || oldHeight === 0) return; - - element.style.width = "auto"; - element.style.height = "auto"; - void element.offsetWidth; - - const newWidth = element.offsetWidth; - const newHeight = element.offsetHeight; - - element.style.width = `${oldWidth}px`; - element.style.height = `${oldHeight}px`; - void element.offsetWidth; - - element.style.width = `${newWidth}px`; - element.style.height = `${newHeight}px`; - - function cleanup() { - element.removeEventListener("transitionend", onEnd); - clearTimeout(fallbackTimer); - pendingCleanup = null; - element.style.width = "auto"; - element.style.height = "auto"; - onComplete?.(); - } - - function onEnd(e: TransitionEvent) { - if (e.target !== element) return; - if (e.propertyName !== "width" && e.propertyName !== "height") return; - cleanup(); - } - - element.addEventListener("transitionend", onEnd); - const fallbackTimer = setTimeout(cleanup, duration + 50); - pendingCleanup = () => { - element.removeEventListener("transitionend", onEnd); - clearTimeout(fallbackTimer); - pendingCleanup = null; - }; -} diff --git a/packages/torph/src/lib/text-morph/utils/segment.ts b/packages/torph/src/lib/text-morph/utils/segment.ts index e7e7791..18336dd 100644 --- a/packages/torph/src/lib/text-morph/utils/segment.ts +++ b/packages/torph/src/lib/text-morph/utils/segment.ts @@ -1,7 +1,5 @@ -export type Segment = { - id: string; - string: string; -}; +export type { Segment } from "../../utils/types"; +import type { Segment } from "../../utils/types"; export function segmentText( value: string, diff --git a/packages/torph/src/lib/utils/animate.ts b/packages/torph/src/lib/utils/animate.ts new file mode 100644 index 0000000..5198cab --- /dev/null +++ b/packages/torph/src/lib/utils/animate.ts @@ -0,0 +1,172 @@ +const MAX_FADE_DURATION = 150; + +export function fadeDuration(duration: number, fraction: number): number { + return Math.min(duration * fraction, MAX_FADE_DURATION); +} + +export function parseTranslate(element: HTMLElement): { + tx: number; + ty: number; +} { + const transform = getComputedStyle(element).transform; + if (!transform || transform === "none") return { tx: 0, ty: 0 }; + const match = transform.match(/matrix\(([^)]+)\)/); + if (!match) return { tx: 0, ty: 0 }; + const v = match[1]!.split(",").map(Number); + return { tx: v[4] || 0, ty: v[5] || 0 }; +} + +export function cancelAnimations(element: HTMLElement): { + tx: number; + ty: number; + opacity: number; +} { + const { tx, ty } = parseTranslate(element); + const opacity = Number(getComputedStyle(element).opacity) || 1; + element.getAnimations().forEach((a) => a.cancel()); + return { tx, ty, opacity }; +} + +export function animateExit( + child: HTMLElement, + options: { + dx: number; + dy: number; + duration: number; + ease: string; + scale: boolean; + }, +) { + const { dx, dy, duration, ease, scale } = options; + + child.animate( + { + transform: scale + ? `translate(${dx}px, ${dy}px) scale(0.95)` + : `translate(${dx}px, ${dy}px)`, + offset: 1, + }, + { + duration, + easing: ease, + fill: "both", + }, + ); + + const fadeAnimation = child.animate( + { + opacity: 0, + offset: 1, + }, + { + duration: fadeDuration(duration, 0.25), + easing: "linear", + fill: "both", + }, + ); + + fadeAnimation.onfinish = () => child.remove(); +} + +export function animateEnterOrPersist( + child: HTMLElement, + options: { + deltaX: number; + deltaY: number; + isNew: boolean; + duration: number; + ease: string; + }, +) { + const { deltaX, deltaY, isNew, duration, ease } = options; + + const prev = cancelAnimations(child); + + const startX = deltaX + prev.tx; + const startY = deltaY + prev.ty; + + child.animate( + { + transform: `translate(${startX}px, ${startY}px) scale(${isNew ? 0.95 : 1})`, + offset: 0, + }, + { + duration, + easing: ease, + fill: "both", + }, + ); + + const startOpacity = isNew && prev.opacity >= 1 ? 0 : prev.opacity; + if (startOpacity < 1) { + child.animate( + [{ opacity: startOpacity }, { opacity: 1 }], + { + duration: fadeDuration(duration, isNew ? 0.5 : 0.25), + easing: "linear", + fill: "both", + }, + ); + } +} + +let pendingCleanup: (() => void) | null = null; + +export function transitionContainerSize( + element: HTMLElement, + oldWidth: number, + oldHeight: number, + duration: number, + onComplete?: () => void, +) { + // Cancel any pending cleanup from a previous transition + if (pendingCleanup) { + pendingCleanup(); + pendingCleanup = null; + } + + if (oldWidth === 0 || oldHeight === 0) { + element.style.width = "auto"; + element.style.height = "auto"; + return; + } + + element.style.width = "auto"; + element.style.height = "auto"; + void element.offsetWidth; + + const newWidth = element.offsetWidth; + const newHeight = element.offsetHeight; + + element.style.width = `${oldWidth}px`; + element.style.height = `${oldHeight}px`; + void element.offsetWidth; + + element.style.width = `${newWidth}px`; + element.style.height = `${newHeight}px`; + + function cleanup() { + element.removeEventListener("transitionend", onEnd); + clearTimeout(fallbackTimer); + pendingCleanup = null; + element.style.width = "auto"; + element.style.height = "auto"; + onComplete?.(); + } + + function onEnd(e: TransitionEvent) { + if (e.target !== element) return; + if (e.propertyName !== "width" && e.propertyName !== "height") return; + cleanup(); + } + + element.addEventListener("transitionend", onEnd); + const fallbackTimer = setTimeout(cleanup, duration + 50); + pendingCleanup = () => { + element.removeEventListener("transitionend", onEnd); + clearTimeout(fallbackTimer); + element.style.width = "auto"; + element.style.height = "auto"; + pendingCleanup = null; + }; +} diff --git a/packages/torph/src/lib/text-morph/utils/constants.ts b/packages/torph/src/lib/utils/constants.ts similarity index 100% rename from packages/torph/src/lib/text-morph/utils/constants.ts rename to packages/torph/src/lib/utils/constants.ts diff --git a/packages/torph/src/lib/text-morph/utils/dom.ts b/packages/torph/src/lib/utils/dom.ts similarity index 98% rename from packages/torph/src/lib/text-morph/utils/dom.ts rename to packages/torph/src/lib/utils/dom.ts index ede02c2..e307e61 100644 --- a/packages/torph/src/lib/text-morph/utils/dom.ts +++ b/packages/torph/src/lib/utils/dom.ts @@ -1,4 +1,4 @@ -import type { Segment } from "./segment"; +import type { Segment } from "./types"; import { ATTR_EXITING, ATTR_ID, ATTR_ITEM } from "./constants"; import { parseTranslate } from "./animate"; diff --git a/packages/torph/src/lib/text-morph/utils/flip.ts b/packages/torph/src/lib/utils/flip.ts similarity index 100% rename from packages/torph/src/lib/text-morph/utils/flip.ts rename to packages/torph/src/lib/utils/flip.ts diff --git a/packages/torph/src/lib/text-morph/utils/reduced-motion.ts b/packages/torph/src/lib/utils/reduced-motion.ts similarity index 100% rename from packages/torph/src/lib/text-morph/utils/reduced-motion.ts rename to packages/torph/src/lib/utils/reduced-motion.ts diff --git a/packages/torph/src/lib/text-morph/utils/spring.ts b/packages/torph/src/lib/utils/spring.ts similarity index 88% rename from packages/torph/src/lib/text-morph/utils/spring.ts rename to packages/torph/src/lib/utils/spring.ts index 3dac06e..37e7636 100644 --- a/packages/torph/src/lib/text-morph/utils/spring.ts +++ b/packages/torph/src/lib/utils/spring.ts @@ -57,6 +57,17 @@ function computeDuration( return Math.ceil(maxDuration * 1000); } +export function resolveEase( + ease: string | SpringParams, + fallbackDuration: number, +): { ease: string; duration: number } { + if (typeof ease === "object") { + const resolved = spring(ease); + return { ease: resolved.easing, duration: resolved.duration }; + } + return { ease, duration: fallbackDuration }; +} + const cache = new Map(); export function spring(params?: SpringParams): SpringResult { diff --git a/packages/torph/src/lib/text-morph/utils/styles.ts b/packages/torph/src/lib/utils/styles.ts similarity index 100% rename from packages/torph/src/lib/text-morph/utils/styles.ts rename to packages/torph/src/lib/utils/styles.ts diff --git a/packages/torph/src/lib/utils/types.ts b/packages/torph/src/lib/utils/types.ts new file mode 100644 index 0000000..59a619d --- /dev/null +++ b/packages/torph/src/lib/utils/types.ts @@ -0,0 +1,25 @@ +import type { SpringParams } from "./spring"; + +export type Segment = { + id: string; + string: string; +}; + +export interface BaseMorphOptions { + element: HTMLElement; + duration?: number; + ease?: string | SpringParams; + locale?: Intl.LocalesArgument; + disabled?: boolean; + respectReducedMotion?: boolean; + onAnimationStart?: () => void; + onAnimationComplete?: () => void; +} + +export const BASE_DEFAULTS = { + locale: "en", + duration: 400, + ease: "cubic-bezier(0.19, 1, 0.22, 1)", + disabled: false, + respectReducedMotion: true, +} as const; diff --git a/packages/torph/src/react/NumberMorph.tsx b/packages/torph/src/react/NumberMorph.tsx new file mode 100644 index 0000000..aec0207 --- /dev/null +++ b/packages/torph/src/react/NumberMorph.tsx @@ -0,0 +1,76 @@ +"use client"; + +import React from "react"; +import { NumberMorphController } from "../lib/number-morph/controller"; +import type { NumberMorphOptions } from "../lib/number-morph/types"; + +export type NumberMorphProps = Omit & { + children: number | string; + cursorIndex?: number; + className?: string; + style?: React.CSSProperties; + as?: React.ElementType; +}; + +function childrenToValue(node: React.ReactNode): number | string { + if (typeof node === "string") return node; + if (typeof node === "number") return node; + if (Array.isArray(node)) return node.map(childrenToValue).join(""); + return ""; +} + +export const NumberMorph = ({ + children, + cursorIndex, + className, + style, + as: Component = "span", + ...props +}: NumberMorphProps) => { + const { ref, update } = useNumberMorph(props); + const value = childrenToValue(children); + const cursorRef = React.useRef(cursorIndex); + cursorRef.current = cursorIndex; + const initialHTML = React.useRef({ + __html: typeof value === "number" ? String(value) : value, + }); + + React.useEffect(() => { + update(value, cursorRef.current); + }, [value, update]); + + return ( + + ); +}; + +export function useNumberMorph(props: Omit) { + const ref = React.useRef(null); + const controllerRef = React.useRef(new NumberMorphController()); + + const configKey = NumberMorphController.serializeConfig(props); + + React.useEffect(() => { + if (ref.current) { + controllerRef.current.attach(ref.current, props); + } + + return () => { + controllerRef.current.destroy(); + }; + }, [configKey]); + + const update = React.useCallback( + (value: number | string, cursorIndex?: number) => { + controllerRef.current.update(value, cursorIndex); + }, + [], + ); + + return { ref, update }; +} diff --git a/packages/torph/src/react/index.ts b/packages/torph/src/react/index.ts index 7536e8c..315ee20 100644 --- a/packages/torph/src/react/index.ts +++ b/packages/torph/src/react/index.ts @@ -1,3 +1,6 @@ export { TextMorph, useTextMorph } from "./TextMorph"; export type { TextMorphProps } from "./TextMorph"; +export { NumberMorph, useNumberMorph } from "./NumberMorph"; +export type { NumberMorphProps } from "./NumberMorph"; + From 2312d1a75ebe12ad67f453837024de831d7da0bd Mon Sep 17 00:00:00 2001 From: Lochie Axon Date: Sat, 7 Mar 2026 09:45:21 +1100 Subject: [PATCH 02/10] example --- .../src/surfaces/homepage/examples/number.tsx | 92 +++++++++++++------ .../homepage/examples/styles.module.scss | 7 +- 2 files changed, 68 insertions(+), 31 deletions(-) diff --git a/site/src/surfaces/homepage/examples/number.tsx b/site/src/surfaces/homepage/examples/number.tsx index b9b80d2..5a1940a 100644 --- a/site/src/surfaces/homepage/examples/number.tsx +++ b/site/src/surfaces/homepage/examples/number.tsx @@ -1,46 +1,84 @@ import styles from "./styles.module.scss"; import React from "react"; -import { TextMorph } from "torph/react"; - -// Simulating typing: showing intermediate states as if someone is typing -const typingSequence = [ - { value: "$", delay: 0 }, // Start typing - { value: "$2", delay: 150 }, // Type 2 - { value: "$20", delay: 120 }, // Type 0 - { value: "$20", delay: 1800 }, // Pause to read - { value: "$", delay: 200 }, // Delete and start new - { value: "$4", delay: 150 }, // Type 4 - { value: "$45", delay: 120 }, // Type 5 - { value: "$45.", delay: 180 }, // Type decimal - { value: "$45.9", delay: 140 }, // Type 9 - { value: "$45.99", delay: 120 }, // Type 9 - { value: "$45.99", delay: 1800 }, // Pause to read - { value: "$", delay: 200 }, // Delete and start new - { value: "$1", delay: 150 }, // Type 1 - { value: "$12", delay: 120 }, // Type 2 - { value: "$12.", delay: 180 }, // Type decimal - { value: "$12.5", delay: 140 }, // Type 5 - { value: "$12.50", delay: 120 }, // Type 0 - { value: "$12.50", delay: 1700 }, // Final pause +import { NumberMorph } from "torph/react"; + +const sequence = [ + // Type $20 + { value: "$", cursor: 1, delay: 0 }, + { value: "$2", cursor: 2, delay: 150 }, + { value: "$20", cursor: 3, delay: 1200 }, + { value: "$20", cursor: 3, delay: 1800 }, + + // Move cursor before 2, then insert 1 → $120 + { value: "$20", cursor: 1, delay: 200 }, + { value: "$420", cursor: 2, delay: 400 }, + { value: "$4,020", cursor: 4, delay: 1800 }, + + // Move cursor between 1 and 2, then insert . → $1.20 + { value: "$420", cursor: 2, delay: 400 }, + { value: "$4.20", cursor: 3, delay: 400 }, + { value: "$4.20", cursor: 3, delay: 1800 }, + + { value: "$4.20", cursor: 5, delay: 1800 }, + { value: "$4.2", cursor: 4, delay: 200 }, + { value: "$4", cursor: 2, delay: 200 }, + { value: "$", cursor: 1, delay: 200 }, ]; export const ExampleNumber = () => { const [currentIndex, setCurrentIndex] = React.useState(0); React.useEffect(() => { - const currentStep = typingSequence[currentIndex]; + const step = sequence[currentIndex]; const timeout = setTimeout(() => { - setCurrentIndex((prevIndex) => (prevIndex + 1) % typingSequence.length); - }, currentStep.delay); + setCurrentIndex((prev) => (prev + 1) % sequence.length); + }, step.delay); return () => clearTimeout(timeout); }, [currentIndex]); + const step = sequence[currentIndex]; + const measureRef = React.useRef(null); + const containerRef = React.useRef(null); + const [cursorX, setCursorX] = React.useState(null); + + React.useLayoutEffect(() => { + if (measureRef.current && containerRef.current) { + const measureRect = measureRef.current.getBoundingClientRect(); + const containerRect = containerRef.current.getBoundingClientRect(); + setCursorX(measureRect.right - containerRect.left); + } + }, [currentIndex]); + return (
- {typingSequence[currentIndex].value} - +
+ {step.value} + + {step.value.slice(0, step.cursor)} + + +
); }; diff --git a/site/src/surfaces/homepage/examples/styles.module.scss b/site/src/surfaces/homepage/examples/styles.module.scss index 699d9b4..3533a61 100644 --- a/site/src/surfaces/homepage/examples/styles.module.scss +++ b/site/src/surfaces/homepage/examples/styles.module.scss @@ -134,13 +134,12 @@ box-shadow: 0 0 0 1px var(--body-light); .cursor { - transform: translateY(1px); border-radius: 1rem; - width: 2px; + width: 1px; height: 0.95em; - background: rgba(255, 255, 255, 0.1); - margin-left: 0.25rem; + background: rgba(255, 255, 255, 0.3); animation: blink 1s step-start infinite; + transition: left 0.3s ease; @keyframes blink { 0% { From 07c251faf0bef887013a116e0de97e8a1148a6c8 Mon Sep 17 00:00:00 2001 From: Lochie Axon Date: Tue, 4 Aug 2026 16:36:52 +1000 Subject: [PATCH 03/10] cleanup + merge latest --- .../number-morph/__tests__/segment.test.ts | 111 ++++++++++++++++ .../torph/src/lib/number-morph/animate.ts | 36 +++--- packages/torph/src/lib/number-morph/index.ts | 107 ++++++++++++++-- .../torph/src/lib/number-morph/segment.ts | 118 +++++++++++++++--- 4 files changed, 334 insertions(+), 38 deletions(-) create mode 100644 packages/torph/src/lib/number-morph/__tests__/segment.test.ts diff --git a/packages/torph/src/lib/number-morph/__tests__/segment.test.ts b/packages/torph/src/lib/number-morph/__tests__/segment.test.ts new file mode 100644 index 0000000..5263894 --- /dev/null +++ b/packages/torph/src/lib/number-morph/__tests__/segment.test.ts @@ -0,0 +1,111 @@ +import { describe, it, expect } from "vitest"; +import { decimalSeparator, segmentNumber } from "../segment"; + +/** + * Where each character of `to` came from in `from`, by position — `null` for a + * character that entered rather than persisted. This is what the FLIP pass + * consumes, so it is the honest description of a morph's cadence. + */ +function alignment(from: string, to: string, decimalChar = ".") { + const before = segmentNumber(from); + const after = segmentNumber(to, before, undefined, decimalChar); + const positions = new Map(before.map((segment, i) => [segment.id, i])); + + return after.map((segment) => positions.get(segment.id) ?? null); +} + +describe("segmentNumber place matching", () => { + it("slides a group separator along by one group", () => { + // The comma belongs to the second group of 1,000,000, not the first: it is + // the same separator moved up a magnitude, not a new one at the front. + expect(alignment("999,999", "1,000,000")).toEqual([ + null, + null, + null, + null, + null, + 3, + null, + null, + null, + ]); + }); + + it("slides separators the other way when the value shrinks", () => { + expect(alignment("12,345", "1,234")).toEqual([null, 2, null, null, null]); + }); + + it("pins a currency prefix and the decimal point", () => { + const places = alignment("$999.50", "$1,000,000.00"); + + expect(places[0]).toBe(0); // $ never moves + expect(places[10]).toBe(4); // decimal point persists + expect(places[6]).toBeNull(); // new separators are new + }); + + it("grows the fraction to the right", () => { + expect(alignment("1.5", "1.55")).toEqual([0, 1, 2, null]); + }); + + it("grows the integer part to the left", () => { + expect(alignment("99", "199")).toEqual([null, 0, 1]); + }); + + it("keeps a trailing unit when the fraction changes length", () => { + expect(alignment("1.25 MB", "1.5 MB")).toEqual([0, 1, null, 4, 5, 6]); + }); + + it("keeps a trailing percent sign while digits shift", () => { + expect(alignment("0%", "50%")).toEqual([null, 0, 1]); + }); + + it("matches by place across a mismatched digit", () => { + // The changed hundreds digit says nothing about the digits either side. + expect(alignment("1,234", "1,834")).toEqual([0, 1, null, 3, 4]); + }); + + it("holds fixed-width separators still", () => { + expect(alignment("09:59", "10:00")).toEqual([null, null, 2, null, null]); + }); + + it("uses the locale's decimal separator as the pivot", () => { + // de-DE: dots group, the comma is the pivot. Both separators slide right. + expect(alignment("1.234,56", "12.345,67", ",")).toEqual([ + null, + null, + 1, + null, + null, + null, + 5, + null, + null, + ]); + }); + + it("prefers the cursor hint when one is given", () => { + const before = segmentNumber("1234"); + const after = segmentNumber("12934", before, 3); + const positions = new Map(before.map((segment, i) => [segment.id, i])); + + // Inserted at index 2, so everything after it keeps its old identity. + expect(after.map((s) => positions.get(s.id) ?? null)).toEqual([ + 0, + 1, + null, + 2, + 3, + ]); + }); +}); + +describe("decimalSeparator", () => { + it("reads the separator from the locale", () => { + expect(decimalSeparator("en")).toBe("."); + expect(decimalSeparator("de-DE")).toBe(","); + }); + + it("falls back to a dot for an invalid locale", () => { + expect(decimalSeparator("en_US")).toBe("."); + }); +}); diff --git a/packages/torph/src/lib/number-morph/animate.ts b/packages/torph/src/lib/number-morph/animate.ts index 4bed86b..5a66e88 100644 --- a/packages/torph/src/lib/number-morph/animate.ts +++ b/packages/torph/src/lib/number-morph/animate.ts @@ -1,8 +1,17 @@ -import { - parseTranslate, - cancelAnimations, - fadeDuration, -} from "../utils/animate"; +import { parseTranslate, cancelAnimations } from "../utils/animate"; + +/** + * Fades are a share of the morph rather than a fixed length, and the outgoing + * share is the larger one: a digit that has already left is a hole in the + * number, so it stays legible well into the slide, while the incoming digit + * asserts itself early instead of ghosting in behind it. + * + * The block-axis mask is doing most of the work here — both sets of characters + * are also being clipped as they cross the line box — so these only have to + * soften the edges of that. + */ +const EXIT_FADE = 0.45; +const ENTER_FADE = 0.25; export function animateNumberExit( child: HTMLElement, @@ -34,12 +43,14 @@ export function animateNumberExit( offset: 1, }, { - duration: fadeDuration(duration, 0.25), + duration: duration * EXIT_FADE, easing: "linear", fill: "both", }, ); + // Removal is the fade finishing, so the share above is also how long an + // exiting character stays in the DOM. fadeAnimation.onfinish = () => child.remove(); } @@ -76,14 +87,11 @@ export function animateNumberEnter( const startOpacity = prev.opacity >= 1 ? 0 : prev.opacity; if (startOpacity < 1) { - child.animate( - [{ opacity: startOpacity }, { opacity: 1 }], - { - duration: fadeDuration(duration, 0.5), - easing: "linear", - fill: "both", - }, - ); + child.animate([{ opacity: startOpacity }, { opacity: 1 }], { + duration: duration * ENTER_FADE, + easing: "linear", + fill: "both", + }); } } diff --git a/packages/torph/src/lib/number-morph/index.ts b/packages/torph/src/lib/number-morph/index.ts index e1776d5..4efb902 100644 --- a/packages/torph/src/lib/number-morph/index.ts +++ b/packages/torph/src/lib/number-morph/index.ts @@ -1,5 +1,9 @@ import type { NumberMorphOptions } from "./types"; -import { type NumberSegment, segmentNumber } from "./segment"; +import { + type NumberSegment, + decimalSeparator, + segmentNumber, +} from "./segment"; import { animateNumberExit, animateNumberEnter, @@ -38,11 +42,14 @@ export const DEFAULT_NUMBER_MORPH_OPTIONS = { ...BASE_DEFAULTS, } as const satisfies Omit; +const MASK_PROPERTIES = ["mask-image", "mask-repeat", "mask-clip"] as const; + export class NumberMorph { private element: HTMLElement; private duration: number; private ease: string; private locale: string; + private decimalChar: string; private decimals?: number; private disabled: boolean; private onAnimationStart?: () => void; @@ -63,6 +70,7 @@ export class NumberMorph { this.duration = duration; this.ease = ease; this.locale = opts.locale!; + this.decimalChar = decimalSeparator(this.locale); this.decimals = opts.decimals; this.disabled = opts.disabled!; this.onAnimationStart = opts.onAnimationStart; @@ -74,8 +82,8 @@ export class NumberMorph { if (!this.isDisabled()) { this.element.setAttribute(ATTR_ROOT, ""); - // Digits slide vertically past the line box on enter/exit - this.element.style.overflow = "hidden"; + this.clipBlockAxis(); + this.fadeBlockEdges(); addStyles(); } } @@ -85,9 +93,73 @@ export class NumberMorph { clearContainerTransition(this.element); this.element.getAnimations().forEach((anim) => anim.cancel()); this.element.removeAttribute(ATTR_ROOT); + this.element.style.overflow = ""; + this.element.style.overflowX = ""; + this.element.style.overflowY = ""; + MASK_PROPERTIES.forEach((property) => { + this.element.style.removeProperty(property); + this.element.style.removeProperty(`-webkit-${property}`); + }); removeStyles(); } + /** + * Digits slide vertically past the line box on enter and exit, so the block + * axis is masked. The inline axis must stay visible: the container spends the + * whole duration animating to its new width, and clipping it would mask every + * character sitting beyond the old width until the size transition catches up. + * + * `clip` is what makes that split legal — `overflow-x: visible` next to + * `overflow-y: hidden` computes to `auto`, which would scroll instead. + */ + private clipBlockAxis() { + if (CSS.supports("overflow", "clip")) { + this.element.style.overflowX = "visible"; + this.element.style.overflowY = "clip"; + } else { + this.element.style.overflow = "hidden"; + } + } + + /** + * Softens the block-axis clip into a gradient, so characters dissolve across + * the edge of the line box instead of meeting a hard line. Positional rather + * than timed: how faint a character is depends on where it has slid to, which + * keeps it in step with its own movement at any duration. + * + * The band is `--torph-fade` on the root — set it to `0` for a hard edge. + * + * `no-clip` is load-bearing. A mask layer is otherwise clipped to the border + * box, which would hide every character sitting beyond the container's + * animating width — exactly what the visible inline axis exists to show. + * `repeat-x` then carries the same profile across that overflow, while the + * block axis stays a single tile so anything above or below the box is masked + * out. Without `no-clip` the mask would cost more than it gives, so the hard + * clip stands in. + */ + private fadeBlockEdges() { + if ( + !CSS.supports("mask-clip", "no-clip") && + !CSS.supports("-webkit-mask-clip", "no-clip") + ) { + return; + } + + const fade = "var(--torph-fade, 0.15em)"; + + this.setMaskProperty( + "mask-image", + `linear-gradient(to bottom, transparent, #000 ${fade}, #000 calc(100% - ${fade}), transparent)`, + ); + this.setMaskProperty("mask-repeat", "repeat-x"); + this.setMaskProperty("mask-clip", "no-clip"); + } + + private setMaskProperty(property: string, value: string) { + this.element.style.setProperty(property, value); + this.element.style.setProperty(`-webkit-${property}`, value); + } + private isDisabled(): boolean { return Boolean( this.disabled || this.reducedMotion?.prefersReducedMotion, @@ -115,7 +187,12 @@ export class NumberMorph { this.onAnimationStart(); } - const segments = segmentNumber(formatted, this.currentSegments, cursorIndex); + const segments = segmentNumber( + formatted, + this.currentSegments, + cursorIndex, + this.decimalChar, + ); this.animate(segments); } @@ -152,6 +229,16 @@ export class NumberMorph { reconcileChildren(element, oldChildren, newIds, segments); this.currentMeasures = measure(element); + + // Frame-0 positions have to be measured with the container still at its old + // width. The root inherits text-align, so under centre/right alignment a + // digit's layout position depends on the container width, and the container + // does not reach its new width until the size transition finishes. + element.style.width = `${oldWidth}px`; + void element.offsetWidth; + const firstFrameMeasures = measure(element); + element.style.width = "auto"; + this.currentSegments = segments; exiting.forEach((child) => { @@ -181,7 +268,7 @@ export class NumberMorph { return; } - this.animateChildren(segments, slideDistance); + this.animateChildren(segments, slideDistance, firstFrameMeasures); transitionContainerSize( element, @@ -193,7 +280,11 @@ export class NumberMorph { ); } - private animateChildren(segments: NumberSegment[], slideDistance: number) { + private animateChildren( + segments: NumberSegment[], + slideDistance: number, + firstFrameMeasures: Measures, + ) { const segmentIds = segments.map((s) => s.id); const persistentIds = new Set( segmentIds.filter((id) => this.prevMeasures[id]), @@ -215,7 +306,7 @@ export class NumberMorph { ); const { dx: deltaX, dy: deltaY } = anchorKey - ? computeDelta(this.prevMeasures, this.currentMeasures, anchorKey) + ? computeDelta(this.prevMeasures, firstFrameMeasures, anchorKey) : { dx: 0, dy: 0 }; animateNumberEnter(child, { @@ -229,7 +320,7 @@ export class NumberMorph { } else { const { dx: deltaX, dy: deltaY } = computeDelta( this.prevMeasures, - this.currentMeasures, + firstFrameMeasures, key, ); diff --git a/packages/torph/src/lib/number-morph/segment.ts b/packages/torph/src/lib/number-morph/segment.ts index 0b4d12b..2a28b27 100644 --- a/packages/torph/src/lib/number-morph/segment.ts +++ b/packages/torph/src/lib/number-morph/segment.ts @@ -6,8 +6,33 @@ export type NumberSegment = { let nextNewId = 0; +function isDigit(char: string): boolean { + return char >= "0" && char <= "9"; +} + function classifyKind(char: string): NumberSegment["kind"] { - return /[0-9]/.test(char) ? "digit" : "symbol"; + return isDigit(char) ? "digit" : "symbol"; +} + +const separators = new Map(); + +/** The locale's decimal separator — the pivot every alignment is measured from. */ +export function decimalSeparator(locale: string): string { + const cached = separators.get(locale); + if (cached) return cached; + + let separator = "."; + try { + separator = + new Intl.NumberFormat(locale) + .formatToParts(1.1) + .find((part) => part.type === "decimal")?.value ?? "."; + } catch { + // Invalid locale tag. `toLocaleString` surfaces it on the first number. + } + + separators.set(locale, separator); + return separator; } /** @@ -17,13 +42,14 @@ function classifyKind(char: string): NumberSegment["kind"] { * characters before the edit keep their old IDs by position, * characters after the edit keep theirs offset by the length change. * - * When `cursorIndex` is not provided, falls back to greedy forward - * matching (works well for distinct characters). + * When `cursorIndex` is not provided, characters are matched by place + * value \u2014 see `placeMatch`. */ export function segmentNumber( value: string, prevSegments?: NumberSegment[], cursorIndex?: number, + decimalChar = ".", ): NumberSegment[] { const chars = value.split(""); @@ -38,7 +64,7 @@ export function segmentNumber( const matches = cursorIndex != null ? cursorMatch(oldChars, chars, cursorIndex) - : greedyMatch(oldChars, chars); + : placeMatch(oldChars, chars, decimalChar); const usedIds = new Set(); for (const [, oldIdx] of matches) { @@ -146,27 +172,87 @@ function cursorMatch( } /** - * Greedy forward matching: matches each old character to the earliest - * available position in the new string. Fallback when no cursor info. + * Matches characters by place value rather than by scanning left to right. + * + * A digit's identity is its significance: the units digit stays the units digit + * however many digits appear in front of it. So the integer side is walked + * outward from the decimal separator towards the left and the fraction side + * towards the right, pairing whatever sits at the same distance from the pivot. + * Group separators fall into place as a side effect — 999,999 → 1,000,000 slides + * its comma along by one group, where a left-to-right scan would snap it to the + * front and break the cadence. * - * Returns a Map of newIndex → oldIndex for matched characters. + * Fixed affixes ($, %, " MB") aren't part of the number, so they are paired from + * the outside in first and excluded from the place alignment. + * + * Returns a Map of newIndex → oldIndex for matched characters. Both walks skip + * over mismatches instead of stopping: one digit changing says nothing about the + * alignment of the digits either side of it. */ -function greedyMatch( +function placeMatch( oldChars: string[], newChars: string[], + decimalChar: string, ): Map { const matches = new Map(); - let newStart = 0; - - for (let i = 0; i < oldChars.length; i++) { - for (let j = newStart; j < newChars.length; j++) { - if (oldChars[i] === newChars[j]) { - matches.set(j, i); - newStart = j + 1; - break; + + let start = 0; + while ( + start < oldChars.length && + start < newChars.length && + oldChars[start] === newChars[start] && + !isDigit(oldChars[start]!) + ) { + matches.set(start, start); + start++; + } + + let oldEnd = oldChars.length; + let newEnd = newChars.length; + while ( + oldEnd > start && + newEnd > start && + oldChars[oldEnd - 1] === newChars[newEnd - 1] && + !isDigit(oldChars[oldEnd - 1]!) + ) { + matches.set(newEnd - 1, oldEnd - 1); + oldEnd--; + newEnd--; + } + + const oldPivot = findPivot(oldChars, start, oldEnd, decimalChar); + const newPivot = findPivot(newChars, start, newEnd, decimalChar); + + for (let k = 1; oldPivot - k >= start && newPivot - k >= start; k++) { + if (oldChars[oldPivot - k] === newChars[newPivot - k]) { + matches.set(newPivot - k, oldPivot - k); + } + } + + // Absent from either value, the pivot is that value's end and there is no + // fraction to walk. + if (oldPivot < oldEnd && newPivot < newEnd) { + matches.set(newPivot, oldPivot); + + for (let k = 1; oldPivot + k < oldEnd && newPivot + k < newEnd; k++) { + if (oldChars[oldPivot + k] === newChars[newPivot + k]) { + matches.set(newPivot + k, oldPivot + k); } } } return matches; } + +/** Last decimal separator within the affix-trimmed range, else the range end. */ +function findPivot( + chars: string[], + start: number, + end: number, + decimalChar: string, +): number { + for (let i = end - 1; i >= start; i--) { + if (chars[i] === decimalChar) return i; + } + return end; +} From 2d4069e090a5bc291d0fe5662ec96b6b40533787 Mon Sep 17 00:00:00 2001 From: Lochie Axon Date: Tue, 1 Sep 2026 23:33:16 +1000 Subject: [PATCH 04/10] playground + examples --- packages/test-cases/src/index.ts | 14 + packages/test-cases/src/number-cases.ts | 279 +++++++++++++++ packages/test-cases/src/number-verify.ts | 221 ++++++++++++ packages/test-cases/src/types.ts | 36 ++ packages/torph/src/index.ts | 2 + .../lib/number-morph/__tests__/cases.test.ts | 21 ++ .../__tests__/corpus-integrity.test.ts | 98 ++++++ packages/torph/src/react/NumberMorph.tsx | 14 +- packages/torph/src/react/TextMorph.tsx | 14 +- .../playground/alignment-inspector.tsx | 82 +++++ site/src/surfaces/playground/config.ts | 10 + site/src/surfaces/playground/index.tsx | 189 ++++++++-- site/src/surfaces/playground/issue.ts | 86 +++++ .../src/surfaces/playground/number-detail.tsx | 325 ++++++++++++++++++ .../surfaces/playground/number-sandbox.tsx | 208 +++++++++++ site/src/surfaces/playground/number-tests.ts | 32 ++ .../surfaces/playground/styles.module.scss | 8 + site/src/surfaces/playground/verify-dom.ts | 40 +++ 18 files changed, 1640 insertions(+), 39 deletions(-) create mode 100644 packages/test-cases/src/number-cases.ts create mode 100644 packages/test-cases/src/number-verify.ts create mode 100644 packages/torph/src/lib/number-morph/__tests__/cases.test.ts create mode 100644 packages/torph/src/lib/number-morph/__tests__/corpus-integrity.test.ts create mode 100644 site/src/surfaces/playground/alignment-inspector.tsx create mode 100644 site/src/surfaces/playground/number-detail.tsx create mode 100644 site/src/surfaces/playground/number-sandbox.tsx create mode 100644 site/src/surfaces/playground/number-tests.ts diff --git a/packages/test-cases/src/index.ts b/packages/test-cases/src/index.ts index 624b626..b3de75a 100644 --- a/packages/test-cases/src/index.ts +++ b/packages/test-cases/src/index.ts @@ -1,4 +1,5 @@ export { ALL_TAGS, CASES } from "./cases"; +export { ALL_NUMBER_TAGS, NUMBER_CASES } from "./number-cases"; export { combineResults, renderSegments, @@ -9,8 +10,21 @@ export { verifyWordAbsent, verifyWordPersistence, } from "./verify"; +export { + alignment, + verifyAlignment, + verifyNoLateralShift, + verifyNumberCycleStability, + verifyPersistedCount, + verifyPlaces, + verifyUniqueIds, +} from "./number-verify"; +export type { AlignOptions } from "./number-verify"; export type { DiffResult, + NumberCase, + NumberSegment, + NumberTorphApi, Result, Segment, TestCase, diff --git a/packages/test-cases/src/number-cases.ts b/packages/test-cases/src/number-cases.ts new file mode 100644 index 0000000..0c3b7f3 --- /dev/null +++ b/packages/test-cases/src/number-cases.ts @@ -0,0 +1,279 @@ +import type { NumberCase } from "./types"; +import { + verifyAlignment, + verifyNoLateralShift, + verifyNumberCycleStability, + verifyPersistedCount, + verifyPlaces, + verifyUniqueIds, +} from "./number-verify"; + +// Run by both the vitest suite in `packages/torph` and the playground at +// `/playground`. Adding a case here adds it to both. +// +// Every assertion is about *place*: which character of the new value came from +// which character of the old one. A number reads as one continuous quantity +// only if each digit keeps its significance across the morph, so that mapping +// is the whole contract. +export const NUMBER_CASES: NumberCase[] = [ + // ── Place matching: digits keep their significance ── + { + label: "Counter tick", + description: + "Only the units digit changes. The hundreds and tens digits should sit perfectly still.", + tags: ["place", "counter"], + values: [100, 101, 102, 103], + verify: (t) => verifyAlignment(t, "100", "101", [0, 1, null]), + }, + { + label: "Integer grows left", + description: + "199 keeps 99 where it is and grows a new hundreds digit on the left, rather than shunting every digit along.", + tags: ["place", "enter"], + values: ["99", "199", "1,199"], + verify: (t) => verifyAlignment(t, "99", "199", [null, 0, 1]), + }, + { + label: "Mismatched digit mid-number", + description: + "A changed hundreds digit says nothing about the digits either side — they hold their places while it swaps.", + tags: ["place"], + values: ["1,234", "1,834"], + verify: (t) => verifyAlignment(t, "1,234", "1,834", [0, 1, null, 3, 4]), + }, + { + label: "Every digit changes", + description: + "Nothing to persist. All four digits exit downward and their replacements enter from above, in place.", + tags: ["enter", "exit"], + values: ["1234", "5678"], + verify: (t) => verifyAlignment(t, "1234", "5678", [null, null, null, null]), + }, + + // ── Group separators ── + { + label: "Separator slides up a magnitude", + description: + "999,999 → 1,000,000: the comma belongs to the second group now, not the first. It should slide one group along, not snap to the front.", + tags: ["separator", "place"], + values: ["999,999", "1,000,000"], + verify: (t) => + verifyAlignment(t, "999,999", "1,000,000", [ + null, + null, + null, + null, + null, + 3, + null, + null, + null, + ]), + }, + { + label: "Separator slides back down", + description: "The same walk in reverse as the value shrinks a magnitude.", + tags: ["separator", "exit"], + values: ["12,345", "1,234"], + verify: (t) => verifyAlignment(t, "12,345", "1,234", [null, 2, null, null, null]), + }, + { + label: "Separator survives a round trip", + description: + "Crossing 10,000 in both directions. The comma must keep one identity — a new one each way means it re-enters on every tick.", + tags: ["separator", "stability"], + values: ["9,999", "10,000"], + verify: (t) => verifyNumberCycleStability(t, "9,999", "10,000", 1), + }, + + // ── Affixes: currency, units, signs ── + { + label: "Currency to millions", + description: + "The $ is fixed and never moves. The decimal point persists across six new digits; the new group separators enter.", + tags: ["currency", "separator", "place"], + values: ["$999.50", "$1,000,000.00"], + verify: (t) => + verifyPlaces(t, "$999.50", "$1,000,000.00", [ + [0, 0], + [10, 4], + [6, null], + ]), + }, + { + label: "Trailing unit held", + description: + 'The " MB" is not part of the number. It should stay put while the fraction changes length underneath it.', + tags: ["unit", "place"], + values: ["1.25 MB", "1.5 MB", "999 MB"], + verify: (t) => verifyAlignment(t, "1.25 MB", "1.5 MB", [0, 1, null, 4, 5, 6]), + }, + { + label: "Percent sign held", + description: + "0% → 50%: the % holds and the 0 slides right into the tens place as a new digit enters in front of it.", + tags: ["unit", "place"], + values: ["0%", "50%", "100%"], + verify: (t) => verifyAlignment(t, "0%", "50%", [null, 0, 1]), + }, + { + label: "Negative sign enters", + description: + "The digit is unchanged, so only the minus animates in — the 5 should not flicker.", + tags: ["enter", "place"], + values: ["5", "-5"], + verify: (t) => verifyAlignment(t, "5", "-5", [null, 0]), + }, + + // ── Fractions and the decimal pivot ── + { + label: "Fraction grows right", + description: + "The fraction side is walked outward from the decimal point, so 1.5 → 1.55 appends rather than shifting.", + tags: ["decimal", "place"], + values: ["1.5", "1.55", "1.555"], + verify: (t) => verifyAlignment(t, "1.5", "1.55", [0, 1, 2, null]), + }, + { + label: "Fixed decimals", + description: + "Formatted to two places by the `decimals` option. Both sides of the point change, so only the point itself persists.", + tags: ["decimal", "decimals"], + values: [3.14159, 2.71828, 1.41421], + decimals: 2, + verify: (t) => verifyPlaces(t, "3.14", "2.72", [[1, 1]]), + }, + { + label: "Fixed-width clock", + description: + "09:59 → 10:00. The colon is the only character that holds; every digit around it changes.", + tags: ["place", "unit"], + values: ["09:59", "10:00", "10:01"], + verify: (t) => verifyAlignment(t, "09:59", "10:00", [null, null, 2, null, null]), + }, + + // ── Locale ── + { + label: "German separators", + description: + "de-DE groups with dots and pivots on the comma. Both separators should slide right by one place, same as the en case.", + tags: ["locale", "separator"], + values: ["1.234,56", "12.345,67"], + locale: "de-DE", + verify: (t) => + verifyAlignment( + t, + "1.234,56", + "12.345,67", + [null, null, 1, null, null, null, 5, null, null], + { decimalChar: "," }, + ), + }, + { + label: "Locale formatting", + description: + "Raw numbers formatted by NumberMorph itself. Grouping follows the locale, so the same value reads differently per step.", + tags: ["locale"], + values: [1234567.891, 9876543.21], + locale: "de-DE", + decimals: 2, + verify: (t) => + verifyPersistedCount(t, "1.234.567,89", "9.876.543,21", 4, { + decimalChar: ",", + }), + }, + + // ── Cursor matching: text input, not a counter ── + { + label: "Cursor insert", + description: + "A caret at index 3 says the 9 was typed there. Everything after it keeps its identity instead of being re-matched by place.", + tags: ["cursor", "enter"], + values: ["1234", "12934"], + cursors: [undefined, 3], + verify: (t) => + verifyAlignment(t, "1234", "12934", [0, 1, null, 2, 3], { cursor: 3 }), + }, + { + label: "Cursor delete", + description: + "Backspacing the last digit of a currency field. The caret pins the rest in place — no reflow of the digits in front.", + tags: ["cursor", "exit"], + values: ["$4.20", "$4.2"], + cursors: [undefined, 4], + verify: (t) => verifyAlignment(t, "$4.20", "$4.2", [0, 1, 2, 3], { cursor: 4 }), + }, + { + label: "Typing a currency field", + description: + "The full type-in from the homepage demo, driven by caret position at every step.", + tags: ["cursor", "currency", "spam"], + values: ["$", "$2", "$20", "$420", "$4,020", "$4.20"], + cursors: [1, 2, 3, 2, 4, 3], + verify: (t) => + verifyUniqueIds(t, ["$", "$2", "$20", "$420", "$4,020", "$4.20"]), + }, + + // ── Tabular figures ── + // + // Tabular numerals give every digit the same advance width. The payoff is a + // number that changes without the characters around it twitching — but the + // font only holds the columns still if the diff agrees nothing moved, so + // these assert that no persisted character changes index. + { + label: "Tabular digits hold their column", + description: + "Same length in and out, so against tabular figures every character should sit in exactly the same place — only the glyphs swap. Any lateral movement here is the diff's fault, not the font's.", + tags: ["tabular", "place"], + values: ["1,234", "9,876", "5,555"], + tabular: true, + verify: (t) => verifyNoLateralShift(t, "1,234", "9,876"), + }, + { + label: "Tabular currency counter", + description: + "A live price ticking under tabular figures: the $, the separators and the decimal all hold their columns while the digits swap underneath them.", + tags: ["tabular", "currency", "counter"], + values: ["$1,234.50", "$9,876.50", "$5,555.55"], + tabular: true, + verify: (t) => verifyNoLateralShift(t, "$1,234.50", "$9,876.50", 4), + }, + { + label: "Tabular width change", + description: + "Crossing a magnitude adds a column, so the number legitimately gets wider. Everything to the right of the new digit still holds its own place — the row grows, it does not slide.", + tags: ["tabular", "separator"], + values: ["9,999", "10,000"], + tabular: true, + verify: (t) => + verifyPlaces(t, "9,999", "10,000", [ + [2, 1], + [0, null], + ]), + }, + + // ── Invariants ── + { + label: "IDs stay unique", + description: + "IDs address DOM children, so a repeat inside one value would make two characters fight over the same node.", + tags: ["ids", "spam"], + values: ["1", "11", "111", "1,111", "11,111", "1,111", "111", "11", "1"], + verify: (t) => + verifyUniqueIds(t, [ + "1", + "11", + "111", + "1,111", + "11,111", + "1,111", + "111", + "11", + "1", + ]), + }, +]; + +export const ALL_NUMBER_TAGS = [ + ...new Set(NUMBER_CASES.flatMap((c) => c.tags)), +].sort(); diff --git a/packages/test-cases/src/number-verify.ts b/packages/test-cases/src/number-verify.ts new file mode 100644 index 0000000..e55f713 --- /dev/null +++ b/packages/test-cases/src/number-verify.ts @@ -0,0 +1,221 @@ +import type { NumberSegment, NumberTorphApi, Result } from "./types"; + +export type AlignOptions = { + /** Caret position in `to`, switching the step to cursor matching. */ + cursor?: number; + decimalChar?: string; +}; + +/** + * Where each character of `to` came from in `from`, by index — `null` for a + * character that entered rather than persisted. + * + * This is the honest description of a morph: it is exactly what the FLIP pass + * consumes, so asserting on it asserts on the cadence you actually see. + */ +export function alignment( + t: NumberTorphApi, + from: string, + to: string, + options: AlignOptions = {}, +): (number | null)[] { + const before = t.segmentNumber(from); + const after = t.segmentNumber( + to, + before, + options.cursor, + options.decimalChar ?? ".", + ); + const positions = new Map(before.map((segment, i) => [segment.id, i])); + + return after.map((segment) => positions.get(segment.id) ?? null); +} + +function render(places: (number | null)[]): string { + return `[${places.map((p) => (p === null ? "·" : p)).join(",")}]`; +} + +/** The whole alignment, exactly. Use when every character's origin matters. */ +export function verifyAlignment( + t: NumberTorphApi, + from: string, + to: string, + expected: (number | null)[], + options: AlignOptions = {}, +): Result { + const places = alignment(t, from, to, options); + const pass = + places.length === expected.length && + places.every((p, i) => p === expected[i]); + + return { + pass, + detail: pass + ? `${render(places)} as expected` + : `expected ${render(expected)}, got ${render(places)}`, + }; +} + +/** + * Individual `[newIndex, oldIndex]` pairs. Use when only some characters carry + * the meaning of the case and the rest are free to land wherever. + */ +export function verifyPlaces( + t: NumberTorphApi, + from: string, + to: string, + pairs: [newIndex: number, oldIndex: number | null][], + options: AlignOptions = {}, +): Result { + const places = alignment(t, from, to, options); + const wrong = pairs.filter(([newIndex, oldIndex]) => places[newIndex] !== oldIndex); + + return { + pass: wrong.length === 0, + detail: wrong.length + ? wrong + .map( + ([newIndex, oldIndex]) => + `"${to[newIndex]}" at ${newIndex} should come from ${ + oldIndex === null ? "nowhere" : oldIndex + }, came from ${places[newIndex] ?? "nowhere"}`, + ) + .join("; ") + : `${pairs.length} place${pairs.length === 1 ? "" : "s"} held ${render(places)}`, + }; +} + +/** How many characters persisted at all — a floor on the morph's continuity. */ +export function verifyPersistedCount( + t: NumberTorphApi, + from: string, + to: string, + min: number, + options: AlignOptions = {}, +): Result { + const places = alignment(t, from, to, options); + const held = places.filter((p) => p !== null).length; + + return { + pass: held >= min, + detail: + held >= min + ? `${held} of ${places.length} characters persisted` + : `only ${held} characters persisted, expected at least ${min}`, + }; +} + +/** + * IDs address DOM children, so a repeat within one segmentation would make two + * characters fight over the same node. + */ +export function verifyUniqueIds( + t: NumberTorphApi, + values: string[], + options: AlignOptions = {}, +): Result { + let prev: NumberSegment[] | undefined; + + for (const value of values) { + const segments = t.segmentNumber( + value, + prev, + options.cursor, + options.decimalChar ?? ".", + ); + const ids = segments.map((s) => s.id); + const duplicate = ids.find((id, i) => ids.indexOf(id) !== i); + + if (duplicate) { + return { pass: false, detail: `"${value}" repeats ID ${duplicate}` }; + } + prev = segments; + } + + return { pass: true, detail: `IDs unique across ${values.length} steps` }; +} + +/** + * A character that never leaves the number must keep one identity across a + * round trip, or it re-enters on every tick of a counter. + */ +export function verifyNumberCycleStability( + t: NumberTorphApi, + a: string, + b: string, + anchorIndex: number, + options: AlignOptions = {}, +): Result { + const decimalChar = options.decimalChar ?? "."; + let segments = t.segmentNumber(a); + const anchor = segments[anchorIndex]; + + if (!anchor) { + return { pass: false, detail: `no character at index ${anchorIndex} of "${a}"` }; + } + + for (let i = 0; i < 4; i++) { + const next = i % 2 === 0 ? b : a; + segments = t.segmentNumber(next, segments, undefined, decimalChar); + + if (!segments.some((s) => s.id === anchor.id)) { + return { + pass: false, + detail: `"${anchor.string}" (${anchor.id}) dropped at cycle ${i + 1} ("${next}")`, + }; + } + } + + const landed = segments.findIndex((s) => s.id === anchor.id); + const pass = landed === anchorIndex; + + return { + pass, + detail: pass + ? `"${anchor.string}" stable at index ${anchorIndex} across 4 cycles` + : `"${anchor.string}" returned to index ${landed}, not ${anchorIndex}`, + }; +} + +/** + * Every character that persists lands on the index it came from. + * + * This is the condition tabular figures depend on. Tabular numerals give every + * digit the same advance width, so a same-length morph can be perfectly still + * horizontally — but only if the diff agrees that nothing moved. One persisted + * character mapped to a different index and the whole row slides, and against a + * monospaced grid that reads as a bug rather than as motion. + * + * `minPersisted` guards the vacuous pass: a diff where nothing persists at all + * has no lateral shift either, and is not what this is asserting. + */ +export function verifyNoLateralShift( + t: NumberTorphApi, + from: string, + to: string, + minPersisted = 1, + options: AlignOptions = {}, +): Result { + const places = alignment(t, from, to, options); + const held = places.filter((p) => p !== null).length; + + if (held < minPersisted) { + return { + pass: false, + detail: `only ${held} characters persisted, expected at least ${minPersisted}`, + }; + } + + const shifted = places + .map((origin, i) => ({ origin, i })) + .filter(({ origin, i }) => origin !== null && origin !== i); + + return { + pass: shifted.length === 0, + detail: shifted.length + ? shifted + .map(({ origin, i }) => `"${to[i]}" slides ${origin} → ${i}`) + .join("; ") + : `${held} characters held their column`, + }; +} diff --git a/packages/test-cases/src/types.ts b/packages/test-cases/src/types.ts index 48e56be..4360c91 100644 --- a/packages/test-cases/src/types.ts +++ b/packages/test-cases/src/types.ts @@ -37,3 +37,39 @@ export type TestCase = { minLines?: number; verify: (t: TorphApi) => Result; }; + +export type NumberSegment = { + id: string; + string: string; + kind: "digit" | "symbol"; +}; + +// Same injection as `TorphApi` above: vitest passes the source function, the +// playground passes the bundled one. +export type NumberTorphApi = { + segmentNumber: ( + value: string, + prevSegments?: NumberSegment[], + cursorIndex?: number, + decimalChar?: string, + ) => NumberSegment[]; +}; + +export type NumberCase = { + label: string; + description: string; + tags: string[]; + /** Rendered through `NumberMorph`; numbers are formatted by it, strings are not. */ + values: (string | number)[]; + /** + * Caret position for each value, switching that step from place matching to + * cursor matching. `undefined` for a step leaves it on place matching. + */ + cursors?: (number | undefined)[]; + locale?: string; + decimals?: number; + align?: "left" | "center" | "right"; + /** Renders the stage with `font-variant-numeric: tabular-nums`. Playground only — needs real layout. */ + tabular?: boolean; + verify: (t: NumberTorphApi) => Result; +}; diff --git a/packages/torph/src/index.ts b/packages/torph/src/index.ts index 8b7f01f..f1c0369 100644 --- a/packages/torph/src/index.ts +++ b/packages/torph/src/index.ts @@ -13,3 +13,5 @@ export type { DiffResult } from "./lib/text-morph/utils/diff"; export { DEFAULT_NUMBER_MORPH_OPTIONS, NumberMorph } from "./lib/number-morph"; export type { NumberMorphOptions } from "./lib/number-morph/types"; +export { decimalSeparator, segmentNumber } from "./lib/number-morph/segment"; +export type { NumberSegment } from "./lib/number-morph/segment"; diff --git a/packages/torph/src/lib/number-morph/__tests__/cases.test.ts b/packages/torph/src/lib/number-morph/__tests__/cases.test.ts new file mode 100644 index 0000000..7fe5602 --- /dev/null +++ b/packages/torph/src/lib/number-morph/__tests__/cases.test.ts @@ -0,0 +1,21 @@ +import { describe, it, expect } from "vitest"; +import { NUMBER_CASES } from "@torph/test-cases"; +import { segmentNumber } from "../segment"; + +// Cases live in `packages/test-cases` — adding one there adds it here and to +// the playground. +describe("shared number cases", () => { + const torph = { segmentNumber }; + + it("has cases to run", () => { + expect(NUMBER_CASES.length).toBeGreaterThan(0); + }); + + it.each(NUMBER_CASES.map((c) => [c.label, c] as const))( + "%s", + (_label, testCase) => { + const { pass, detail } = testCase.verify(torph); + expect(pass, detail).toBe(true); + }, + ); +}); diff --git a/packages/torph/src/lib/number-morph/__tests__/corpus-integrity.test.ts b/packages/torph/src/lib/number-morph/__tests__/corpus-integrity.test.ts new file mode 100644 index 0000000..17f4954 --- /dev/null +++ b/packages/torph/src/lib/number-morph/__tests__/corpus-integrity.test.ts @@ -0,0 +1,98 @@ +import { describe, it, expect } from "vitest"; +import { NUMBER_CASES } from "@torph/test-cases"; +import type { NumberSegment, NumberTorphApi } from "@torph/test-cases"; +import { segmentNumber } from "../segment"; + +// Guards the corpus, not the library: a `verify` can pass without asserting +// anything and still look green. Each case is re-run against deliberately +// broken segmenters — passing under every one means it tests nothing. + +type Saboteur = { name: string; api: NumberTorphApi }; + +const SABOTEURS: Saboteur[] = [ + { + name: "nothing persists — every character gets a fresh ID", + api: { + segmentNumber: (value, prev, cursor, decimalChar) => { + let n = 0; + return segmentNumber(value, prev, cursor, decimalChar).map( + (s: NumberSegment) => ({ ...s, id: `fresh-${n++}` }), + ); + }, + }, + }, + { + name: "identity is position — the units digit is whatever sits last", + api: { + segmentNumber: (value, prev, cursor, decimalChar) => + segmentNumber(value, prev, cursor, decimalChar).map( + (s: NumberSegment, i: number) => ({ ...s, id: `at-${i}` }), + ), + }, + }, + { + name: "identity is the character — every 0 in a value is the same 0", + api: { + segmentNumber: (value, prev, cursor, decimalChar) => + segmentNumber(value, prev, cursor, decimalChar).map( + (s: NumberSegment) => ({ ...s, id: `char-${s.string}` }), + ), + }, + }, + { + name: "frozen — the segmenter ignores the new value", + api: { + segmentNumber: (value, prev, cursor, decimalChar) => + prev?.length ? prev : segmentNumber(value, prev, cursor, decimalChar), + }, + }, + { + name: "empty output — the segmenter returns nothing", + api: { + segmentNumber: () => [], + }, + }, +]; + +describe("number corpus integrity", () => { + it("every case is uniquely labelled", () => { + const labels = NUMBER_CASES.map((c) => c.label); + expect(new Set(labels).size, "duplicate case labels").toBe(labels.length); + }); + + it("every case has values to morph between", () => { + for (const c of NUMBER_CASES) { + expect(c.values.length, `"${c.label}" has no values`).toBeGreaterThan(0); + } + }); + + it("every cursor track covers its values", () => { + for (const c of NUMBER_CASES) { + if (!c.cursors) continue; + expect( + c.cursors.length, + `"${c.label}" has ${c.cursors.length} cursors for ${c.values.length} values`, + ).toBe(c.values.length); + } + }); + + it.each(NUMBER_CASES.map((c) => [c.label, c] as const))( + "%s detects a broken segmenter", + (_label, testCase) => { + const survived = SABOTEURS.filter((s) => { + try { + return testCase.verify(s.api).pass; + } catch { + return false; + } + }); + + expect( + survived.length, + `passes under every saboteur (${survived + .map((s) => s.name) + .join(", ")}) — this case does not actually assert library behaviour`, + ).toBeLessThan(SABOTEURS.length); + }, + ); +}); diff --git a/packages/torph/src/react/NumberMorph.tsx b/packages/torph/src/react/NumberMorph.tsx index 010be4f..ff5cf2a 100644 --- a/packages/torph/src/react/NumberMorph.tsx +++ b/packages/torph/src/react/NumberMorph.tsx @@ -55,10 +55,22 @@ export function useNumberMorph(props: Omit) { const configKey = NumberMorphController.serializeConfig(props); + // Callbacks are deliberately absent from the config key — changing one should + // not tear the morph down. That leaves them captured at attach time, so they + // are called through a ref instead: a handler closing over component state is + // the normal case, and a frozen one would silently keep reading the state it + // saw on mount. + const handlers = React.useRef(props); + handlers.current = props; + React.useEffect(() => { const controller = controllerRef.current; if (ref.current) { - controller.attach(ref.current, props); + controller.attach(ref.current, { + ...props, + onAnimationStart: () => handlers.current.onAnimationStart?.(), + onAnimationComplete: () => handlers.current.onAnimationComplete?.(), + }); } return () => { diff --git a/packages/torph/src/react/TextMorph.tsx b/packages/torph/src/react/TextMorph.tsx index e728d9a..4790c65 100644 --- a/packages/torph/src/react/TextMorph.tsx +++ b/packages/torph/src/react/TextMorph.tsx @@ -71,10 +71,22 @@ export function useTextMorph(props: Omit) { const configKey = MorphController.serializeConfig(props); + // Callbacks are deliberately absent from the config key — changing one should + // not tear the morph down. That leaves them captured at attach time, so they + // are called through a ref instead: a handler closing over component state is + // the normal case, and a frozen one would silently keep reading the state it + // saw on mount. + const handlers = React.useRef(props); + handlers.current = props; + React.useEffect(() => { const controller = controllerRef.current; if (ref.current) { - controller.attach(ref.current, props); + controller.attach(ref.current, { + ...props, + onAnimationStart: () => handlers.current.onAnimationStart?.(), + onAnimationComplete: () => handlers.current.onAnimationComplete?.(), + }); } return () => { diff --git a/site/src/surfaces/playground/alignment-inspector.tsx b/site/src/surfaces/playground/alignment-inspector.tsx new file mode 100644 index 0000000..89ea553 --- /dev/null +++ b/site/src/surfaces/playground/alignment-inspector.tsx @@ -0,0 +1,82 @@ +import React from "react"; +import styles from "./styles.module.scss"; +import { numberTorph } from "./number-tests"; +import { alignment } from "@torph/test-cases"; + +/** + * Where every character of the new value came from. Unlike the text inspector + * this reports an origin *index* rather than a bare persisted/entered flag — + * for a number, which character it came from is the whole question. + */ +export function AlignmentInspector({ + from, + to, + cursor, + decimalChar, +}: { + from: string; + to: string; + cursor?: number; + decimalChar: string; +}) { + const places = alignment(numberTorph, from, to, { cursor, decimalChar }); + const held = new Set(places.filter((p): p is number => p !== null)); + const show = (v: string) => (v === " " ? "·" : v); + + return ( +
+
+ before +
+ {from.split("").map((char, i) => ( + + {show(char)} + {i} + + ))} +
+
+
+ after +
+ {to.split("").map((char, i) => { + const origin = places[i]; + return ( + + {show(char)} + {origin === null ? "new" : `←${origin}`} + + ); + })} +
+
+
+ pivot +
+ + {decimalChar} + decimal separator + + + {cursor == null ? "place" : `cursor ${cursor}`} + matching + +
+
+
+ ); +} diff --git a/site/src/surfaces/playground/config.ts b/site/src/surfaces/playground/config.ts index 579365e..0e4e9d8 100644 --- a/site/src/surfaces/playground/config.ts +++ b/site/src/surfaces/playground/config.ts @@ -16,3 +16,13 @@ export type Speed = keyof typeof SPEEDS; export const ALIGNS = ["left", "center", "right"] as const; export type Align = (typeof ALIGNS)[number]; + +// ── NumberMorph ── + +// en-IN earns its place: lakh/crore grouping (12,34,567) puts separators where +// no other locale does, so it catches grouping assumed to be every three digits. +export const LOCALES = ["en", "de-DE", "en-IN"] as const; +export type Locale = (typeof LOCALES)[number]; + +export const DECIMALS = { auto: undefined, "0": 0, "2": 2 } as const; +export type DecimalsKey = keyof typeof DECIMALS; diff --git a/site/src/surfaces/playground/index.tsx b/site/src/surfaces/playground/index.tsx index d579282..d2f8b93 100644 --- a/site/src/surfaces/playground/index.tsx +++ b/site/src/surfaces/playground/index.tsx @@ -4,14 +4,20 @@ import React from "react"; import styles from "./styles.module.scss"; import pkg from "../../../../packages/torph/package.json"; import { TESTS } from "./tests"; -import type { Speed, EasingKey, Align } from "./config"; -import { SPEEDS, EASINGS, ALIGNS } from "./config"; +import { NUMBER_TESTS } from "./number-tests"; +import type { Speed, EasingKey, Align, Locale, DecimalsKey } from "./config"; +import { SPEEDS, EASINGS, ALIGNS, LOCALES, DECIMALS } from "./config"; import { TestDetail } from "./test-detail"; import { SandboxCard } from "./sandbox-card"; +import { NumberDetail } from "./number-detail"; +import { NumberSandbox } from "./number-sandbox"; import type { Result } from "@torph/test-cases"; const SANDBOX = -1; +const MODES = ["text", "numbers"] as const; +type Mode = (typeof MODES)[number]; + type BundleSize = { name: string; gzip: number; @@ -23,33 +29,53 @@ export const Playground = ({ }: { bundleSizes?: BundleSize[]; }) => { - const [selected, setSelected] = React.useState(0); + const [mode, setMode] = React.useState("text"); + // Kept per mode so switching back lands on the case you left. + const [selection, setSelection] = React.useState>({ + text: 0, + numbers: 0, + }); const [filter, setFilter] = React.useState(""); const [speed, setSpeed] = React.useState("default"); const [easing, setEasing] = React.useState("default"); const [align, setAlign] = React.useState("left"); const [debug, setDebug] = React.useState(false); + const [locale, setLocale] = React.useState("en"); + const [decimals, setDecimals] = React.useState("auto"); + const [tabular, setTabular] = React.useState(false); + + const cases = mode === "text" ? TESTS : NUMBER_TESTS; + const selected = selection[mode]!; + const select = (index: number) => + setSelection((s) => ({ ...s, [mode]: index })); - const [results, setResults] = React.useState<(Result | null)[]>(() => - TESTS.map(() => null), + const [results, setResults] = React.useState>( + () => ({ text: TESTS.map(() => null), numbers: NUMBER_TESTS.map(() => null) }), ); React.useEffect(() => { - setResults(TESTS.map((t) => t.verify())); + setResults({ + text: TESTS.map((t) => t.verify()), + numbers: NUMBER_TESTS.map((t) => t.verify()), + }); }, []); + const modeResults = results[mode]!; + const query = filter.trim().toLowerCase(); - const visible = TESTS.map((_, i) => i).filter((i) => { - if (!query) return true; - const t = TESTS[i]!; - return ( - t.label.toLowerCase().includes(query) || - t.tags.some((tag) => tag.toLowerCase().includes(query)) - ); - }); + const visible = cases + .map((_, i) => i) + .filter((i) => { + if (!query) return true; + const t = cases[i]!; + return ( + t.label.toLowerCase().includes(query) || + t.tags.some((tag) => tag.toLowerCase().includes(query)) + ); + }); - const failing = results.filter((r) => r && !r.pass).length; - const ran = results.filter(Boolean).length; - const current = selected >= 0 ? TESTS[selected] : null; + const failing = modeResults.filter((r) => r && !r.pass).length; + const ran = modeResults.filter(Boolean).length; + const current = selected >= 0 ? cases[selected] : null; return (
@@ -65,6 +91,24 @@ export const Playground = ({ v{pkg.version}
+
+ {MODES.map((m) => ( + + ))} +
+ setSelected(SANDBOX)} + onClick={() => select(SANDBOX)} > Sandbox {visible.map((i) => { - const r = results[i]; + const r = modeResults[i]; return ( ); })} @@ -183,38 +227,109 @@ export const Playground = ({ ))} - + + {mode === "text" ? ( + + ) : ( + <> +
+ {LOCALES.map((l) => ( + + ))} +
+
+ {(Object.keys(DECIMALS) as DecimalsKey[]).map((d) => ( + + ))} +
+ + + )} - {current ? ( - setFilter(tag)} + /> + ) : ( + + ) + ) : current ? ( + setFilter(tag)} /> ) : ( - )}

Space morph · cases live in{" "} - packages/test-cases/src/cases.ts + + packages/test-cases/src/ + {mode === "text" ? "cases.ts" : "number-cases.ts"} +

diff --git a/site/src/surfaces/playground/issue.ts b/site/src/surfaces/playground/issue.ts index b85997c..70bdcb8 100644 --- a/site/src/surfaces/playground/issue.ts +++ b/site/src/surfaces/playground/issue.ts @@ -1,7 +1,11 @@ import type { Result } from "@torph/test-cases"; +import { alignment } from "@torph/test-cases"; import pkg from "../../../../packages/torph/package.json"; import { torph } from "./tests"; import type { BenchCase } from "./tests"; +import { numberTorph, formatValue } from "./number-tests"; +import type { NumberBenchCase } from "./number-tests"; +import { decimalSeparator } from "torph"; import { SPEEDS, EASINGS } from "./config"; import type { Speed, EasingKey, Align } from "./config"; @@ -97,3 +101,85 @@ export async function copyText(value: string): Promise { return false; } } + +// ── NumberMorph ── + +function placeTable( + from: string, + to: string, + cursor: number | undefined, + decimalChar: string, +): string { + const places = alignment(numberTorph, from, to, { cursor, decimalChar }); + + return [ + "| index | char | came from |", + "|---|---|---|", + ...to + .split("") + .map( + (char, i) => + `| ${i} | \`${char}\` | ${ + places[i] === null ? "entered" : `index ${places[i]}` + } |`, + ), + ].join("\n"); +} + +export function buildNumberIssueReport({ + test, + index, + speed, + easing, + align, + locale, + decimals, + cursor, + result, + notes, +}: { + test: NumberBenchCase; + index: number; + speed: Speed; + easing: EasingKey; + align: Align; + locale: string; + decimals: number | undefined; + cursor: number | undefined; + result: Result | null; + notes?: string; +}): string { + const prev = (index - 1 + test.values.length) % test.values.length; + const from = formatValue(test.values[prev]!, locale, decimals); + const to = formatValue(test.values[index]!, locale, decimals); + const ease = EASINGS[easing]; + + return [ + `# torph issue — ${test.label} (NumberMorph)`, + "", + notes + ? `**What looks wrong:** ${notes}` + : "**What looks wrong:** _(describe it)_", + "", + `- **torph**: v${pkg.version}`, + `- **case**: \`${test.label}\` (${test.tags.join(", ")})`, + `- **description**: ${test.description}`, + `- **step**: ${index + 1}/${test.values.length}`, + `- **from** → **to**: ${JSON.stringify(from)} → ${JSON.stringify(to)}`, + `- **all values**: ${JSON.stringify(test.values)}`, + `- **matching**: ${cursor == null ? "place value" : `cursor at ${cursor}`}`, + `- **locale**: ${locale} (decimal separator \`${decimalSeparator(locale)}\`)`, + `- **decimals**: ${decimals ?? "auto"}`, + `- **duration**: ${SPEEDS[speed]}ms (${speed})`, + `- **ease**: ${typeof ease === "string" ? ease : JSON.stringify(ease)} (${easing})`, + `- **align**: ${test.align ?? align}${test.align ? " (fixed by case)" : ""}`, + "", + `## Assertion`, + result ? `${result.pass ? "PASS" : "FAIL"} — ${result.detail}` : "not run", + "", + `## Places`, + placeTable(from, to, cursor, decimalSeparator(locale)), + "", + `_Case defined in \`packages/test-cases/src/number-cases.ts\`._`, + ].join("\n"); +} diff --git a/site/src/surfaces/playground/number-detail.tsx b/site/src/surfaces/playground/number-detail.tsx new file mode 100644 index 0000000..ffd524e --- /dev/null +++ b/site/src/surfaces/playground/number-detail.tsx @@ -0,0 +1,325 @@ +import React from "react"; +import { NumberMorph } from "torph/react"; +import { decimalSeparator } from "torph"; +import styles from "./styles.module.scss"; +import { formatValue } from "./number-tests"; +import type { NumberBenchCase } from "./number-tests"; +import { buildNumberIssueReport, copyText } from "./issue"; +import { SPEEDS, EASINGS, DECIMALS } from "./config"; +import type { Speed, EasingKey, Align, Locale, DecimalsKey } from "./config"; +import type { Result } from "@torph/test-cases"; +import { AlignmentInspector } from "./alignment-inspector"; +import type { JumpSnapshot, PerfResult } from "./verify-dom"; +import { + FrameMonitor, + takeJumpSnapshot, + verifyDomStandard, + verifyNoJump, + verifyTabularDigits, +} from "./verify-dom"; +import { combineResults } from "@torph/test-cases"; + +export function NumberDetail({ + test, + result, + speed, + easing, + align: globalAlign, + locale: globalLocale, + decimals: globalDecimals, + tabular: globalTabular, + onTagClick, +}: { + test: NumberBenchCase; + result: Result | null; + speed: Speed; + easing: EasingKey; + align: Align; + locale: Locale; + decimals: DecimalsKey; + tabular: boolean; + onTagClick?: (tag: string) => void; +}) { + const [index, setIndex] = React.useState(0); + const [showInspector, setShowInspector] = React.useState(false); + const [showChecks, setShowChecks] = React.useState(false); + const [useCursors, setUseCursors] = React.useState(true); + const [auto, setAuto] = React.useState(false); + const [copied, setCopied] = React.useState(false); + const [notes, setNotes] = React.useState(""); + + // A case that pins a locale or a decimal count is testing that setting, so it + // wins over the toolbar the same way `test.align` does. + const align = test.align ?? globalAlign; + const locale = test.locale ?? globalLocale; + const decimals = test.decimals ?? DECIMALS[globalDecimals]; + const decimalChar = decimalSeparator(locale); + // A case that opts into tabular figures is testing them, so it cannot be + // switched off from the toolbar — but any case can be switched on. + const tabular = test.tabular || globalTabular; + + const [dom, setDom] = React.useState(null); + const [jump, setJump] = React.useState(null); + const [perf, setPerf] = React.useState(null); + + const stageRef = React.useRef(null); + const preMorph = React.useRef(null); + const pendingJump = React.useRef(null); + const frames = React.useRef(new FrameMonitor()); + + const advance = React.useCallback(() => { + setIndex((i) => (i + 1) % test.values.length); + }, [test.values.length]); + + React.useEffect(() => { + setIndex(0); + setAuto(false); + setNotes(""); + setCopied(false); + setDom(null); + setJump(null); + setPerf(null); + }, [test.label]); + + React.useEffect(() => { + const monitor = frames.current; + return () => { + monitor.stop(); + }; + }, []); + + const root = () => + stageRef.current?.querySelector("[torph-root]") ?? null; + + const handleStart = () => { + if (!showChecks) return; + frames.current.start(); + const el = root(); + if (!el) return; + preMorph.current = takeJumpSnapshot(el); + // Sampled a frame in, but reported on completion — a state update + // mid-animation would re-render the thing being measured. + requestAnimationFrame(() => { + if (preMorph.current) { + pendingJump.current = verifyNoJump(el, preMorph.current); + } + }); + }; + + const handleComplete = () => { + if (!showChecks) return; + setPerf(frames.current.stop()); + if (pendingJump.current) { + setJump(pendingJump.current); + pendingJump.current = null; + } + const el = root(); + if (!el) return; + setDom( + tabular + ? combineResults(verifyDomStandard(el), verifyTabularDigits(el)) + : verifyDomStandard(el), + ); + }; + + React.useEffect(() => { + if (!auto) return; + const id = setInterval(advance, 150); + return () => clearInterval(id); + }, [auto, advance]); + + React.useEffect(() => { + function onKey(e: KeyboardEvent) { + if ( + e.target instanceof HTMLInputElement || + e.target instanceof HTMLTextAreaElement + ) + return; + if (e.code === "Space") { + e.preventDefault(); + advance(); + } + } + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [advance]); + + const prev = (index - 1 + test.values.length) % test.values.length; + const cursor = + useCursors && test.cursors ? test.cursors[index] : undefined; + + const handleCopy = async () => { + const ok = await copyText( + buildNumberIssueReport({ + test, + index, + speed, + easing, + align, + locale, + decimals, + cursor, + result, + notes: notes.trim() || undefined, + }), + ); + setCopied(ok); + setTimeout(() => setCopied(false), 2000); + }; + + return ( +
+
+

{test.label}

+
+ {test.tags.map((tag) => ( + + ))} +
+
+ +

{test.description}

+ +
e.key === "Enter" && advance()} + > + + {test.values[index]!} + +
+ +
+ + {index + 1} / {test.values.length} + + + {test.tags.includes("spam") && ( + + )} + {test.cursors && ( + + )} + {test.tabular && ( + + tabular + + )} + + + + + {result ? (result.pass ? "PASS" : "FAIL") : "…"} + +
+ + {result &&

{result.detail}

} + + {showChecks && ( +
+ {( + [ + ["DOM", dom], + ["JUMP", jump], + [ + "FRAMES", + perf + ? { + pass: perf.pass, + detail: `${perf.totalFrames} frames, ${perf.droppedFrames} dropped, longest ${perf.longestFrame.toFixed(1)}ms — ${perf.detail}`, + } + : null, + ], + ] as const + ).map(([name, r]) => ( +
+ + {r ? (r.pass ? "PASS" : "FAIL") : "…"} + + {name} + + {r ? r.detail : "morph to run"} + +
+ ))} +
+ )} + + {showInspector && ( + + )} + +
+ setNotes(e.target.value)} + /> + +
+
+ ); +} diff --git a/site/src/surfaces/playground/number-sandbox.tsx b/site/src/surfaces/playground/number-sandbox.tsx new file mode 100644 index 0000000..0767634 --- /dev/null +++ b/site/src/surfaces/playground/number-sandbox.tsx @@ -0,0 +1,208 @@ +import React from "react"; +import { NumberMorph } from "torph/react"; +import { decimalSeparator } from "torph"; +import styles from "./styles.module.scss"; +import { SPEEDS, EASINGS, DECIMALS } from "./config"; +import type { Speed, EasingKey, Align, Locale, DecimalsKey } from "./config"; +import { AlignmentInspector } from "./alignment-inspector"; + +type Decomposed = { + prefix: string; + suffix: string; + value: number; + fractionDigits: number; +}; + +/** + * Splits a typed value into its fixed affixes and the quantity between them, so + * the stepper buttons can do arithmetic on "$1,234.50 /mo" without eating the + * "$" or the "/mo". + */ +function decompose(value: string, decimalChar: string): Decomposed | null { + const first = value.search(/\d/); + if (first === -1) return null; + + let last = value.length - 1; + while (last >= 0 && !/\d/.test(value[last]!)) last--; + + const body = value.slice(first, last + 1); + const digits = [...body].filter((c) => /\d/.test(c) || c === decimalChar); + const point = digits.indexOf(decimalChar); + const parsed = Number(digits.join("").replace(decimalChar, ".")); + + if (!Number.isFinite(parsed)) return null; + + return { + prefix: value.slice(0, first), + suffix: value.slice(last + 1), + value: parsed, + fractionDigits: point === -1 ? 0 : digits.length - point - 1, + }; +} + +function recompose( + parts: Decomposed, + next: number, + locale: string, + fractionDigits: number, +): string { + return ( + parts.prefix + + next.toLocaleString(locale, { + minimumFractionDigits: fractionDigits, + maximumFractionDigits: fractionDigits, + }) + + parts.suffix + ); +} + +/** Module scope so the impurity stays out of the component body. */ +function randomQuantity(): number { + const magnitude = 10 ** Math.floor(Math.random() * 7); + return Math.floor(Math.random() * magnitude); +} + +const STEPS: [label: string, apply: (n: number) => number][] = [ + ["−1", (n) => n - 1], + ["+1", (n) => n + 1], + ["÷10", (n) => n / 10], + ["×10", (n) => n * 10], + ["±", (n) => -n], +]; + +export function NumberSandbox({ + speed, + easing, + align, + locale, + decimals, + tabular, +}: { + speed: Speed; + easing: EasingKey; + align: Align; + locale: Locale; + decimals: DecimalsKey; + tabular: boolean; +}) { + const [value, setValue] = React.useState("$1,234.50"); + const [previous, setPrevious] = React.useState("$1,234.50"); + const [cursor, setCursor] = React.useState(undefined); + const [useCursor, setUseCursor] = React.useState(true); + + const decimalChar = decimalSeparator(locale); + const parts = decompose(value, decimalChar); + + const commit = (next: string, caret?: number) => { + setPrevious(value); + setValue(next); + setCursor(caret); + }; + + // A stepper is a counter, not an edit — there is no caret behind it, so these + // always go through place matching however the toggle is set. + const step = (apply: (n: number) => number) => { + if (!parts) return; + const next = apply(parts.value); + const fractionDigits = Number.isInteger(next) ? parts.fractionDigits : 2; + commit(recompose(parts, next, locale, fractionDigits), undefined); + }; + + const randomise = () => { + if (!parts) return; + commit( + recompose(parts, randomQuantity(), locale, parts.fractionDigits), + undefined, + ); + }; + + const activeCursor = useCursor ? cursor : undefined; + + return ( +
+
+

Sandbox

+
+ custom +
+
+ +

+ Type in the field and the morph follows your caret — this is the shape a + currency input takes in production. The steppers have no caret, so they + fall back to place matching, the shape a counter takes. Nothing here is + asserted. +

+ +
+ +
+ +
+ + {value} + +
+ +
+ {STEPS.map(([label, apply]) => ( + + ))} + + + +
+ + +
+ ); +} diff --git a/site/src/surfaces/playground/number-tests.ts b/site/src/surfaces/playground/number-tests.ts new file mode 100644 index 0000000..998c3f3 --- /dev/null +++ b/site/src/surfaces/playground/number-tests.ts @@ -0,0 +1,32 @@ +import { segmentNumber } from "torph"; +import { NUMBER_CASES, ALL_NUMBER_TAGS } from "@torph/test-cases"; +import type { NumberCase, NumberTorphApi, Result } from "@torph/test-cases"; + +// Cases live in `packages/test-cases`, shared with the vitest suite. This only +// binds them to the bundled package. +export const numberTorph: NumberTorphApi = { segmentNumber }; + +export type NumberBenchCase = Omit & { + verify: () => Result; +}; + +export const NUMBER_TESTS: NumberBenchCase[] = NUMBER_CASES.map((testCase) => ({ + ...testCase, + verify: () => testCase.verify(numberTorph), +})); + +export { ALL_NUMBER_TAGS }; + +/** What `NumberMorph` will render for a value — numbers are formatted, strings are not. */ +export function formatValue( + value: string | number, + locale: string, + decimals?: number, +): string { + return typeof value === "number" + ? value.toLocaleString(locale, { + minimumFractionDigits: decimals, + maximumFractionDigits: decimals, + }) + : value; +} diff --git a/site/src/surfaces/playground/styles.module.scss b/site/src/surfaces/playground/styles.module.scss index e5e7b4a..4c8692e 100644 --- a/site/src/surfaces/playground/styles.module.scss +++ b/site/src/surfaces/playground/styles.module.scss @@ -559,3 +559,11 @@ $fail: rgb(248, 113, 113); max-height: 20rem; } } + +// ── Mode switch ── + +.modeBtn { + flex: 1; + padding: 0.35rem 0.5rem; + text-align: center; +} diff --git a/site/src/surfaces/playground/verify-dom.ts b/site/src/surfaces/playground/verify-dom.ts index 49009d2..399f29d 100644 --- a/site/src/surfaces/playground/verify-dom.ts +++ b/site/src/surfaces/playground/verify-dom.ts @@ -534,3 +534,43 @@ export function measurePerf( const elapsed = performance.now() - start; return { ...result, timeMs: elapsed / iterations }; } + +/** + * Every rendered digit occupies the same advance width. + * + * The corpus asserts that no persisted character changes index; this asserts + * the other half of the tabular promise, which only real layout can answer — + * that the font actually delivers equal-width figures and that nothing in the + * morph (a stray transform, a per-character style) has widened one of them. + */ +export function verifyTabularDigits(root: HTMLElement): { + pass: boolean; + detail: string; +} { + const digits = Array.from( + root.querySelectorAll("[torph-item]:not([torph-exiting])"), + ).filter((item) => /^\d$/.test(item.textContent ?? "")); + + if (digits.length < 2) { + return { pass: true, detail: `${digits.length} digits — nothing to compare` }; + } + + const tolerance = 0.5; + const widths = digits.map((d) => d.getBoundingClientRect().width); + const min = Math.min(...widths); + const max = Math.max(...widths); + + if (max - min > tolerance) { + const widest = digits[widths.indexOf(max)]!.textContent; + const narrowest = digits[widths.indexOf(min)]!.textContent; + return { + pass: false, + detail: `digit widths vary by ${(max - min).toFixed(2)}px ("${widest}" ${max.toFixed(2)}px vs "${narrowest}" ${min.toFixed(2)}px) — not tabular`, + }; + } + + return { + pass: true, + detail: `${digits.length} digits share a ${max.toFixed(2)}px column`, + }; +} From 21e79ca49da13bd295b7f89e81f2a2d63230a6d8 Mon Sep 17 00:00:00 2001 From: Lochie Axon Date: Wed, 2 Sep 2026 00:03:52 +1000 Subject: [PATCH 05/10] more demos --- packages/test-cases/src/number-cases.ts | 75 +++++ .../surfaces/playground/chart-demo/index.tsx | 58 ++++ .../playground/chart-demo/styles.module.scss | 58 ++++ site/src/surfaces/playground/index.tsx | 252 ++++++++------- .../surfaces/playground/input-demo/index.tsx | 33 ++ .../playground/input-demo/input/index.tsx | 286 ++++++++++++++++++ .../input-demo/input/styles.module.scss | 187 ++++++++++++ .../playground/input-demo/input/useMouse.ts | 81 +++++ .../playground/input-demo/input/utils.ts | 60 ++++ .../playground/input-demo/styles.module.scss | 40 +++ .../surfaces/playground/styles.module.scss | 25 ++ site/src/surfaces/playground/ticker-demo.tsx | 151 +++++++++ 12 files changed, 1204 insertions(+), 102 deletions(-) create mode 100644 site/src/surfaces/playground/chart-demo/index.tsx create mode 100644 site/src/surfaces/playground/chart-demo/styles.module.scss create mode 100644 site/src/surfaces/playground/input-demo/index.tsx create mode 100644 site/src/surfaces/playground/input-demo/input/index.tsx create mode 100644 site/src/surfaces/playground/input-demo/input/styles.module.scss create mode 100644 site/src/surfaces/playground/input-demo/input/useMouse.ts create mode 100644 site/src/surfaces/playground/input-demo/input/utils.ts create mode 100644 site/src/surfaces/playground/input-demo/styles.module.scss create mode 100644 site/src/surfaces/playground/ticker-demo.tsx diff --git a/packages/test-cases/src/number-cases.ts b/packages/test-cases/src/number-cases.ts index 0c3b7f3..28201c2 100644 --- a/packages/test-cases/src/number-cases.ts +++ b/packages/test-cases/src/number-cases.ts @@ -1,4 +1,5 @@ import type { NumberCase } from "./types"; +import { combineResults } from "./verify"; import { verifyAlignment, verifyNoLateralShift, @@ -183,6 +184,23 @@ export const NUMBER_CASES: NumberCase[] = [ }), }, + { + label: "French narrow spaces", + description: + "fr-FR groups with a narrow no-break space (U+202F) rather than a glyph. It should walk out from the decimal comma exactly like any other separator.", + tags: ["locale", "separator", "space"], + values: ["1\u202F234,56", "12\u202F345,67", "1\u202F234\u202F567,89"], + locale: "fr-FR", + verify: (t) => + verifyAlignment( + t, + "1\u202F234,56", + "12\u202F345,67", + [null, null, 1, null, null, null, 5, null, null], + { decimalChar: "," }, + ), + }, + // ── Cursor matching: text input, not a counter ── { label: "Cursor insert", @@ -214,6 +232,41 @@ export const NUMBER_CASES: NumberCase[] = [ verifyUniqueIds(t, ["$", "$2", "$20", "$420", "$4,020", "$4.20"]), }, + // ── Symbols and affixes that are not currency ── + { + label: "Currency symbol swaps", + description: + "Only the symbol changes. Every digit, the separator and the decimal point should be perfectly still — this is the case where any wobble is unambiguously a bug.", + tags: ["currency", "place"], + values: ["$99.00", "€99.00", "£99.00", "¥99.00"], + verify: (t) => verifyNoLateralShift(t, "$99.00", "€99.00", 5), + }, + { + label: "Delta badge", + description: + "A signed percentage. The sign flips and the digits change, but the decimal point and the % hold their places on either side of them.", + tags: ["unit", "sign"], + values: ["+2.4%", "\u22120.8%", "+11.2%", "0.0%"], + verify: (t) => + verifyAlignment(t, "+2.4%", "\u22120.8%", [null, null, 2, null, 4]), + }, + { + label: "Compact suffix", + description: + "999K → 1.2K rewrites the number and grows a decimal point, but the K is an affix and belongs where it already is.", + tags: ["unit", "decimal"], + values: ["999K", "1.2K", "12.4M", "1.1B"], + verify: (t) => verifyAlignment(t, "999K", "1.2K", [null, null, null, 3]), + }, + { + label: "Scoreline", + description: + "Spaces are segments too. Only the digit that actually changed should move; the spaces and the dash between them hold.", + tags: ["space", "place"], + values: ["0 - 0", "1 - 0", "1 - 1", "2 - 1"], + verify: (t) => verifyNoLateralShift(t, "0 - 0", "1 - 0", 4), + }, + // ── Tabular figures ── // // Tabular numerals give every digit the same advance width. The payoff is a @@ -252,6 +305,28 @@ export const NUMBER_CASES: NumberCase[] = [ ]), }, + // ── Edges ── + { + label: "Repeated digit shrinks", + description: + "Which of four identical 1s survives? Place matching keeps the rightmost three — the units digit stays the units digit — rather than the leftmost, which would shift the whole number one column left.", + tags: ["repeat", "place"], + values: ["1111", "111", "11", "1"], + verify: (t) => verifyAlignment(t, "1111", "111", [1, 2, 3]), + }, + { + label: "Empty and back", + description: + "Nothing to match against, so both characters enter fresh. Collapsing to zero width is the rough edge here — watch the container, not the diff.", + tags: ["empty", "container"], + values: ["", "42", ""], + verify: (t) => + combineResults( + verifyAlignment(t, "", "42", [null, null]), + verifyUniqueIds(t, ["", "42", ""]), + ), + }, + // ── Invariants ── { label: "IDs stay unique", diff --git a/site/src/surfaces/playground/chart-demo/index.tsx b/site/src/surfaces/playground/chart-demo/index.tsx new file mode 100644 index 0000000..db5dd32 --- /dev/null +++ b/site/src/surfaces/playground/chart-demo/index.tsx @@ -0,0 +1,58 @@ +"use client"; + +import React from "react"; +import { NumberMorph } from "torph/react"; +import styles from "./styles.module.scss"; + +const DATA = [ + { month: "Jan", value: 4120 }, + { month: "Feb", value: 3840 }, + { month: "Mar", value: 5230 }, + { month: "Apr", value: 4780 }, + { month: "May", value: 6150 }, + { month: "Jun", value: 5890 }, + { month: "Jul", value: 7240 }, + { month: "Aug", value: 6870 }, + { month: "Sep", value: 7590 }, + { month: "Oct", value: 8120 }, + { month: "Nov", value: 7430 }, + { month: "Dec", value: 9210 }, +]; + +const MAX_VALUE = Math.max(...DATA.map((d) => d.value)); + +function formatValue(value: number): string { + return "$" + value.toLocaleString("en-US"); +} + +export const ChartPlayground = () => { + const [activeIndex, setActiveIndex] = React.useState(DATA.length - 1); + + return ( +
+
+ {formatValue(DATA[activeIndex].value)} +
+
+ Monthly revenue · {DATA[activeIndex].month} +
+
+ {DATA.map((item, i) => ( +
setActiveIndex(i)} + /> + ))} +
+
+ {DATA.map((item) => ( +
+ {item.month} +
+ ))} +
+
+ ); +}; diff --git a/site/src/surfaces/playground/chart-demo/styles.module.scss b/site/src/surfaces/playground/chart-demo/styles.module.scss new file mode 100644 index 0000000..84c2f55 --- /dev/null +++ b/site/src/surfaces/playground/chart-demo/styles.module.scss @@ -0,0 +1,58 @@ +.container { + position: relative; + padding: 2rem; + font-family: var(--font-secondary); + color: #ffffff; + border-radius: 1rem; + background: var(--body-light); +} + +.value { + font-size: 2.5rem; + font-weight: 600; + margin-bottom: 0.25rem; +} + +.label { + font-size: 0.875rem; + color: #888; + margin-bottom: 2rem; +} + +.chart { + display: flex; + align-items: flex-end; + gap: 6px; + height: 160px; +} + +.bar { + flex: 1; + border-radius: 4px 4px 0 0; + background: #363636; + cursor: pointer; + transition: background 0.15s ease; + min-width: 0; + + &:hover, + &.active { + background: #fff; + } +} + +.months { + display: flex; + gap: 6px; + margin-top: 8px; +} + +.month { + flex: 1; + text-align: center; + font-size: 0.7rem; + color: #555; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} diff --git a/site/src/surfaces/playground/index.tsx b/site/src/surfaces/playground/index.tsx index d2f8b93..8784017 100644 --- a/site/src/surfaces/playground/index.tsx +++ b/site/src/surfaces/playground/index.tsx @@ -11,13 +11,39 @@ import { TestDetail } from "./test-detail"; import { SandboxCard } from "./sandbox-card"; import { NumberDetail } from "./number-detail"; import { NumberSandbox } from "./number-sandbox"; +import { ChartPlayground } from "./chart-demo"; +import { InputPlayground } from "./input-demo"; +import { TickerDemo } from "./ticker-demo"; import type { Result } from "@torph/test-cases"; +// Negative ids sit outside the corpus — they select a panel, not a case. const SANDBOX = -1; +const CHART_DEMO = -2; +const INPUT_DEMO = -3; +const TICKER_DEMO = -4; const MODES = ["text", "numbers"] as const; type Mode = (typeof MODES)[number]; +// Scoped per mode: the chart and input demos drive NumberMorph, so they have +// nothing to show under text. The mode switch already says "numbers", so the +// labels don't repeat it. +const PANELS: Record = { + text: [{ id: SANDBOX, label: "Sandbox" }], + numbers: [ + { id: SANDBOX, label: "Sandbox" }, + { id: TICKER_DEMO, label: "Ticker" }, + { id: CHART_DEMO, label: "Chart" }, + { id: INPUT_DEMO, label: "Input" }, + ], +}; + +// The chart and input demos style their own numbers, so the shared duration, +// easing and alignment controls would sit there doing nothing. The ticker runs +// on them — its whole point is the interval measured against the duration. +const SELF_STYLED: number[] = [CHART_DEMO, INPUT_DEMO]; +const DEMOS: number[] = [CHART_DEMO, INPUT_DEMO, TICKER_DEMO]; + type BundleSize = { name: string; gzip: number; @@ -77,6 +103,9 @@ export const Playground = ({ const ran = modeResults.filter(Boolean).length; const current = selected >= 0 ? cases[selected] : null; + const isSelfStyled = SELF_STYLED.includes(selected); + const isDemo = DEMOS.includes(selected); + return (
-
-
- {(Object.keys(SPEEDS) as Speed[]).map((s) => ( - - ))} -
-
- {(Object.keys(EASINGS) as EasingKey[]).map((e) => ( - - ))} -
-
- {ALIGNS.map((a) => ( + {!isSelfStyled && ( +
+
+ {(Object.keys(SPEEDS) as Speed[]).map((s) => ( + + ))} +
+
+ {(Object.keys(EASINGS) as EasingKey[]).map((e) => ( + + ))} +
+
+ {ALIGNS.map((a) => ( + + ))} +
+ {mode === "text" ? ( - ))} + ) : ( + <> +
+ {LOCALES.map((l) => ( + + ))} +
+
+ {(Object.keys(DECIMALS) as DecimalsKey[]).map((d) => ( + + ))} +
+ + + )}
- - {mode === "text" ? ( - - ) : ( - <> -
- {LOCALES.map((l) => ( - - ))} -
-
- {(Object.keys(DECIMALS) as DecimalsKey[]).map((d) => ( - - ))} -
- - - )} -
+ )} {mode === "text" ? ( current ? ( @@ -313,6 +346,19 @@ export const Playground = ({ tabular={tabular} onTagClick={(tag) => setFilter(tag)} /> + ) : selected === CHART_DEMO ? ( + + ) : selected === INPUT_DEMO ? ( + + ) : selected === TICKER_DEMO ? ( + ) : ( )} -

- Space morph · cases live in{" "} - - packages/test-cases/src/ - {mode === "text" ? "cases.ts" : "number-cases.ts"} - -

+ {!isDemo && ( +

+ Space morph · cases live in{" "} + + packages/test-cases/src/ + {mode === "text" ? "cases.ts" : "number-cases.ts"} + +

+ )}
); diff --git a/site/src/surfaces/playground/input-demo/index.tsx b/site/src/surfaces/playground/input-demo/index.tsx new file mode 100644 index 0000000..ef2fa4f --- /dev/null +++ b/site/src/surfaces/playground/input-demo/index.tsx @@ -0,0 +1,33 @@ +"use client"; + +import { NumberMorph } from "torph/react"; +import styles from "./styles.module.scss"; + +import React from "react"; +import { InputNumber } from "./input"; + +export const InputPlayground = () => { + const [query, setQuery] = React.useState(""); + const [cursor, setCursor] = React.useState(); + const inputRef = React.useRef(null); + + return ( +
+ +
+ { + setQuery(e.target.value); + setCursor(inputRef.current?.selectionStart ?? undefined); + }} + /> +
+ {query} +
+
+
+ ); +}; diff --git a/site/src/surfaces/playground/input-demo/input/index.tsx b/site/src/surfaces/playground/input-demo/input/index.tsx new file mode 100644 index 0000000..3e624bb --- /dev/null +++ b/site/src/surfaces/playground/input-demo/input/index.tsx @@ -0,0 +1,286 @@ +"use client"; + +import { useEffect, useId, useRef, useState } from "react"; + +import { AnimatePresence, motion } from "motion/react"; + +import styles from "./styles.module.scss"; +import { stringToLocaleString } from "./utils"; +import { useMouse } from "./useMouse"; +import { NumberMorph } from "torph/react"; + +const MAX_FONT_SIZE = 80; +const MIN_FONT_SIZE = 16; + +const validateInput = (value: string, decimals: number) => { + if (value === ".") throw new Error("Cannot start with a dot"); + if (value.includes("..")) throw new Error("Cannot include multiple dots"); + if (value.match(/\d+\.\d+\./)) + throw new Error("Cannot include multiple dots"); + if (value.match(/\./g) && value.split(".")[1].length > decimals) + throw new Error(`Cannot include more than ${decimals} decimal places`); + if (value.match(/^0\d/)) throw new Error("Cannot include leading zeros"); + if (value.match(/^\./)) throw new Error("Cannot include leading dots"); + if (!value.match(/^\d*\.?\d*$/)) + throw new Error("Cannot include invalid characters"); + + if (parseFloat(value) > Number.MAX_SAFE_INTEGER) + throw new Error("Cannot exceed maximum safe integer"); + return true; +}; + +export type InputProps = { + max?: number; + decimals?: number; + prefix?: string; + value?: string; + onValueChange?: (value: string) => void; + disabled?: boolean; + autoFocus?: boolean; +}; + +export const InputNumber = ({ + max = 1000000000000, + decimals = 2, + prefix = "", + value: initialValue, + onValueChange, + disabled, + autoFocus, +}: InputProps) => { + const id = useId(); + const [value, setValue] = useState(initialValue ?? ""); + + useEffect(() => { + const raw = initialValue ?? ""; + setValue(raw); + if (inputRef.current) { + inputRef.current.value = stringToLocaleString(raw); + } + updateCaret(); + }, [initialValue]); + + const { mouse } = useMouse(); + + const formattedValue = stringToLocaleString(value); + + const inputContainerRef = useRef(null); + const inputRef = useRef(null); + const caretRef = useRef(null); + const hiddenRef = useRef(null); + + const updateContainerSize = () => { + if (inputContainerRef.current && inputRef.current) { + const container = inputContainerRef.current; + const input = inputRef.current; + const displayLength = input.value.length || 1; + + const fontSize = Math.min( + MAX_FONT_SIZE, + Math.max(MIN_FONT_SIZE, container.offsetWidth / displayLength), + ); + + container.style.fontSize = `${fontSize}px`; + } + }; + + useEffect(() => { + updateContainerSize(); + }, [value]); + + const updateValidValue = (v: string, target: HTMLInputElement) => { + const oldFormatted = stringToLocaleString(value); + const newFormatted = stringToLocaleString(v); + const cursorPos = target.selectionStart ?? newFormatted.length; + const cursorOffset = newFormatted.length - oldFormatted.length; + + setValue(v); + onValueChange?.(v); + target.value = newFormatted; + + const newCursor = Math.max(0, cursorPos + cursorOffset); + target.setSelectionRange(newCursor, newCursor); + + updateContainerSize(); + updateCaret({ value: newFormatted }); + }; + + const getCaretPosition = (v: string, start: number, end: number) => { + if (!(inputRef.current && hiddenRef.current && caretRef.current)) { + return { + width: 0, + x: 0, + }; + } + //document.body.appendChild(hiddenRef.current); // show element + const hidden = hiddenRef.current; + hidden.innerHTML = v.substring(start, end); + //hidden.remove(); // hide element + return { + width: 1, + x: hidden.offsetWidth, + }; + }; + + const selectAll = () => { + const input = inputRef.current; + const caret = caretRef.current; + if (input && caret) { + input.setSelectionRange(0, input.value.length, "forward"); + requestAnimationFrame(() => { + updateCaret(); + caret.style.left = "0px"; + caret.style.transform = `translateX(0px)`; + }); + } + }; + + const updateCaret = ({ + isMouse, + value: overrideValue, + }: { + isMouse?: boolean; + value?: string; + } = {}) => { + if (isMouse && !mouse.isDown) return; + const v = overrideValue ?? formattedValue; + if (inputRef.current && hiddenRef.current && caretRef.current) { + const caret = caretRef.current; + const input = inputRef.current; + const hidden = hiddenRef.current; + + const caretX = + caret.getBoundingClientRect().left + caret.offsetWidth * 0.5; + const selectionDirectionForwards = isMouse + ? mouse.horizontal === "right" + ? mouse.x > caretX + : mouse.x > caretX + : input.selectionDirection === "forward"; + + caret.setAttribute("data-blink", "false"); + requestAnimationFrame(() => { + const start = input.selectionStart ?? 0; + const end = input.selectionEnd ?? v.toString().length; + const length = end - start; + + hidden.style.maxWidth = `${input.offsetWidth}px`; + if (input.value.length > 0 && length === input.value.length) { + const c = getCaretPosition(v, start, end); + caret.style.width = `${c.x}px`; + caret.style.left = `0px`; + caret.style.transform = `translateX(0px)`; + } else if (length > 0) { + const c = getCaretPosition(v, start, end); + caret.style.width = `${c.x}px`; + caret.style.left = selectionDirectionForwards ? `0px` : `-${c.x}px`; + } else { + // caret position + const c = getCaretPosition( + v, + 0, + !selectionDirectionForwards ? start : end, + ); + caret.style.transform = `translateX(${c.x}px)`; + + caret.style.width = `${c.width}px`; + caret.style.left = "0px"; + caret.setAttribute("data-blink", "true"); + } + }); + } + }; + + return ( +
+
+
+
+ + {prefix && ( + + {prefix} + + )} + +
+
+ + {formattedValue || "0"} + +
+
+
+ updateCaret({ isMouse: true })} + onMouseMove={() => updateCaret({ isMouse: true })} + onTouchMove={() => updateCaret({ isMouse: true })} + onClick={() => updateCaret({ isMouse: true })} + onKeyDown={() => updateCaret()} + onDoubleClick={() => selectAll()} + onChange={(e) => { + if (disabled) return; + e.preventDefault(); + let v = e.target.value.replace(/,/g, ""); + if (v[0] === ".") v = "0" + v; + if (v === "00") { + e.target.value = formattedValue; + if (e.target.getAttribute("data-pop")) return; + e.target.setAttribute("data-pop", "true"); + setTimeout(() => { + e.target.removeAttribute("data-pop"); + }, 200); + return; + } + try { + if (max !== undefined && parseFloat(v) > max) + throw new Error("Cannot exceed maximum value"); + validateInput(v, decimals); + updateValidValue(v, e.target); + } catch { + e.target.value = formattedValue; + if (e.target.getAttribute("data-invalid")) return; + e.target.setAttribute("data-invalid", "true"); + setTimeout(() => { + e.target.removeAttribute("data-invalid"); + }, 200); + } + }} + /> + +
+
+ ); +}; diff --git a/site/src/surfaces/playground/input-demo/input/styles.module.scss b/site/src/surfaces/playground/input-demo/input/styles.module.scss new file mode 100644 index 0000000..5f2b966 --- /dev/null +++ b/site/src/surfaces/playground/input-demo/input/styles.module.scss @@ -0,0 +1,187 @@ +@keyframes shake { + 0% { + transform: translateX(0); + } + 25% { + transform: translateX(-0.15em); + } + 50% { + transform: translateX(0.15em); + } + 75% { + transform: translateX(-0.15em); + } + 100% { + transform: translateX(0); + } +} +@keyframes pop { + 0% { + transform: scale(1); + } + 40% { + transform: scale(1.1); + } + 100% { + transform: scale(1); + } +} + +.container { + min-height: 6em; + display: flex; + align-items: center; + justify-content: center; + + position: relative; + user-select: none; + transition: color 100ms ease; + + &:has(input:focus) { + .caret { + display: block; + } + } + + &:has(input:disabled) { + &:not(:has(input[readonly])) { + cursor: not-allowed; + } + } + + &:has(input[value=""]) { + color: var(--color-muted); + } +} + +.inputContainer { + cursor: text; + display: flex; + align-items: center; + font-size: 5rem; + font-weight: 700; + letter-spacing: -0.05em; + line-height: 120%; + transition: font-size 100ms ease; + //font-variant-numeric: tabular-nums; + + &:has(input[data-invalid="true"]) { + animation: shake ease 200ms both; + } + + &:has(input[data-pop="true"]) { + animation: pop ease 200ms both; + } +} +.input { + width: 100%; + color: transparent; + text-align: center; + background: transparent; + font: inherit !important; + outline: none; + border: none; + box-shadow: none; + user-select: auto; + caret-color: transparent; + + // remove arrows from number input + -moz-appearance: textfield; + appearance: textfield; + &::-webkit-inner-spin-button, + &::-webkit-outer-spin-button { + -webkit-appearance: none; + margin: 0; + } + + &::selection { + background: transparent; + } + + &:focus { + outline: none; + } +} + +.value { + position: relative; +} +.valueContainer { + pointer-events: none; + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + font: inherit; +} + +.valueContainerInner { + position: relative; + display: flex; + align-items: center; + justify-content: center; + width: 100%; + overflow: hidden; + + sup { + display: block; + position: relative; + margin-right: -1em; + right: 1em; + top: -0.25em; + font-size: 0.5em; + font-weight: 900; + line-height: 1; + padding-right: 0.2em; + } + + mask-image: linear-gradient( + to bottom, + transparent 0%, + black 0.125em, + black calc(100% - 0.125em), + transparent 100% + ); + + > span { + &:before { + display: block; + content: " "; + position: absolute; + left: -0.75em; + top: 0.25em; + font-size: 0.5em; + } + } +} + +@keyframes caretBlink { + 0% { + opacity: 1; + } + 50% { + opacity: 0; + } +} + +.caret { + display: none; + transition: 100ms ease; + transition-property: width, left, transform; + position: absolute; + top: 15%; + bottom: 12%; + width: 1px; + &[data-blink="true"] { + transition-property: none; + animation: caretBlink steps(1) 1060ms infinite; + } + &:before { + content: ""; + position: absolute; + inset: -1px; + border-radius: 0.125em; + background: var(--primary); + } +} diff --git a/site/src/surfaces/playground/input-demo/input/useMouse.ts b/site/src/surfaces/playground/input-demo/input/useMouse.ts new file mode 100644 index 0000000..120abff --- /dev/null +++ b/site/src/surfaces/playground/input-demo/input/useMouse.ts @@ -0,0 +1,81 @@ +import { useEffect, useState } from "react"; +type MouseProps = { + x: number; + y: number; + xv: number; + yv: number; + isDown: boolean; + horizontal: "left" | "right"; + vertical: "top" | "bottom"; +}; + +export const useMouse = () => { + const [mouse, setMouse] = useState({ + x: 0, + y: 0, + xv: 0, + yv: 0, + isDown: false, + horizontal: "left", + vertical: "top", + }); + + const onUpdate = (e: MouseEvent) => { + setMouse((prev) => ({ + x: e.clientX, + y: e.clientY, + xv: e.movementX, + yv: e.movementY, + isDown: e.buttons === 1, + horizontal: + e.movementX > 0 ? "right" : e.movementX < 0 ? "left" : prev.horizontal, + vertical: + e.movementY > 0 ? "bottom" : e.movementY < 0 ? "top" : prev.vertical, + })); + }; + const onTouchUpdate = (e: TouchEvent) => { + if (e.touches.length === 0) return; + setMouse((prev) => ({ + x: e.touches[0].clientX, + y: e.touches[0].clientY, + xv: e.touches[0].clientX - prev.x, + yv: e.touches[0].clientY - prev.y, + isDown: e.touches.length > 0, + horizontal: + e.touches[0].clientX - prev.x > 0 + ? "right" + : e.touches[0].clientX - prev.x < 0 + ? "left" + : prev.horizontal, + vertical: + e.touches[0].clientY - prev.y > 0 + ? "bottom" + : e.touches[0].clientY - prev.y < 0 + ? "top" + : prev.vertical, + })); + }; + + useEffect(() => { + window.addEventListener("mousemove", onUpdate); + window.addEventListener("mousedown", onUpdate); + window.addEventListener("mouseup", onUpdate); + + window.addEventListener("touchmove", onTouchUpdate); + window.addEventListener("touchstart", onTouchUpdate); + window.addEventListener("touchend", onTouchUpdate); + return () => { + window.removeEventListener("mousemove", onUpdate); + window.removeEventListener("mousedown", onUpdate); + window.removeEventListener("mouseup", onUpdate); + + window.removeEventListener("touchmove", onTouchUpdate); + window.removeEventListener("touchstart", onTouchUpdate); + window.removeEventListener("touchend", onTouchUpdate); + }; + }, []); + + return { + mouse, + }; +}; diff --git a/site/src/surfaces/playground/input-demo/input/utils.ts b/site/src/surfaces/playground/input-demo/input/utils.ts new file mode 100644 index 0000000..2edcc8f --- /dev/null +++ b/site/src/surfaces/playground/input-demo/input/utils.ts @@ -0,0 +1,60 @@ +export const extractFromLocaleString = ( + value: string, + start: number, + end: number, +) => { + // Convert the number to a localized string (including commas/periods) + const localeString = stringToLocaleString(value); + + // Initialize variables for the result and a counter to track numeric characters + let result = ""; + let numericCount = 0; + + // Iterate over the localized string + for (let i = 0; i < localeString.length; i++) { + const char = localeString[i]; + + // If the character is a number or decimal point + if (/[0-9.]/.test(char)) { + if (numericCount >= start && numericCount < end) { + result += char; + } + numericCount++; // Only increment when counting numeric characters + } else { + // If it's a comma, add it to the result only if we're within the selected range + if (numericCount > start && numericCount <= end) { + result += char; + } + } + } + + // trim the result to remove any leading commas + return result + .split("") + .reverse() + .join("") + .replace(/^,*/, "") + .split("") + .reverse() + .join(""); +}; + +export const stringToLocaleString = (value: string) => { + if (value === "") return ""; + if (value.includes(".")) { + const [integer, decimal] = value.split("."); + return `${parseFloat(integer).toLocaleString()}.${decimal}`; + } else { + return parseFloat(value).toLocaleString(); + } +}; + +export const rawToFormattedIndex = (raw: string, rawIndex: number): number => { + const formatted = stringToLocaleString(raw); + let rawCount = 0; + for (let i = 0; i < formatted.length; i++) { + if (rawCount === rawIndex) return i; + if (/[0-9.]/.test(formatted[i])) rawCount++; + } + return formatted.length; +}; diff --git a/site/src/surfaces/playground/input-demo/styles.module.scss b/site/src/surfaces/playground/input-demo/styles.module.scss new file mode 100644 index 0000000..a234604 --- /dev/null +++ b/site/src/surfaces/playground/input-demo/styles.module.scss @@ -0,0 +1,40 @@ +.container { + position: relative; + padding: 6rem 2rem; + font-family: var(--font-secondary); + font-size: 2.5rem; + font-weight: 500; + color: #ffffff; + border-radius: 1rem; + background: var(--body-light); +} + +.input { + position: relative; + padding: 0.5rem 1rem; + font-family: var(--font-secondary); + font-size: 2rem; + font-weight: 500; + color: #ffffff; + border-radius: 0.5rem; + border: 1px solid #363636; + + input { + all: unset; + position: relative; + z-index: 1; + width: 100%; + color: transparent; + caret-color: white; + font: inherit; + } + + .output { + position: absolute; + inset: 0; + padding: inherit; + pointer-events: none; + display: flex; + align-items: center; + } +} diff --git a/site/src/surfaces/playground/styles.module.scss b/site/src/surfaces/playground/styles.module.scss index 4c8692e..eb5ae77 100644 --- a/site/src/surfaces/playground/styles.module.scss +++ b/site/src/surfaces/playground/styles.module.scss @@ -567,3 +567,28 @@ $fail: rgb(248, 113, 113); padding: 0.35rem 0.5rem; text-align: center; } + +// ── Ticker ── + +.range { + display: inline-flex; + align-items: center; + gap: 0.4rem; + + input[type="range"] { + width: 6rem; + accent-color: var(--primary); + } + + code { + font-family: monospace; + font-size: 0.62rem; + color: rgba(255, 255, 255, 0.35); + font-variant-numeric: tabular-nums; + } +} + +.tagActive { + background: rgba(255, 255, 255, 0.14); + color: rgba(255, 255, 255, 0.85); +} diff --git a/site/src/surfaces/playground/ticker-demo.tsx b/site/src/surfaces/playground/ticker-demo.tsx new file mode 100644 index 0000000..2680083 --- /dev/null +++ b/site/src/surfaces/playground/ticker-demo.tsx @@ -0,0 +1,151 @@ +import React from "react"; +import { NumberMorph } from "torph/react"; +import styles from "./styles.module.scss"; +import { SPEEDS, EASINGS, DECIMALS } from "./config"; +import type { Speed, EasingKey, Align, Locale, DecimalsKey } from "./config"; + +const FORMATS = ["count", "currency", "percent", "compact"] as const; +type Format = (typeof FORMATS)[number]; + +function format(value: number, kind: Format, locale: string): string { + switch (kind) { + case "currency": + return `$${value.toLocaleString(locale, { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + })}`; + case "percent": + return `${(value / 100).toFixed(1)}%`; + case "compact": + return value >= 1000 + ? `${(value / 1000).toFixed(1)}K` + : `${Math.round(value)}`; + default: + return Math.round(value).toLocaleString(locale); + } +} + +/** Module scope so the impurity stays out of the component body. */ +function walkFrom(value: number): number { + const next = value * (1 + (Math.random() - 0.48) * 0.15); + return Math.max(1, Math.min(999999, next)); +} + +/** + * Interval-driven updates, the one shape neither the case corpus nor the + * sandbox produces: values landing on top of animations that have not finished. + * Drop the interval below the duration and every morph interrupts a running + * one, which is where cancelled-animation and stale-transform bugs surface. + */ +export function TickerDemo({ + speed, + easing, + align, + locale, + decimals, + tabular, +}: { + speed: Speed; + easing: EasingKey; + align: Align; + locale: Locale; + decimals: DecimalsKey; + tabular: boolean; +}) { + const [value, setValue] = React.useState(1234.56); + const [kind, setKind] = React.useState("currency"); + const [gap, setGap] = React.useState(700); + const [running, setRunning] = React.useState(false); + + const walk = React.useCallback(() => setValue(walkFrom), []); + + React.useEffect(() => { + if (!running) return; + const id = window.setInterval(walk, gap); + return () => window.clearInterval(id); + }, [running, gap, walk]); + + // Eight updates at 60ms regardless of the interval — a burst lands inside any + // duration the toolbar offers. + const burst = React.useCallback(() => { + for (let i = 0; i < 8; i++) window.setTimeout(walk, i * 60); + }, [walk]); + + const duration = SPEEDS[speed]; + const interrupting = gap < duration; + + return ( +
+
+

Ticker

+
+ {FORMATS.map((f) => ( + + ))} +
+
+ +

+ A live value on a timer. Set the interval below the duration and each + update interrupts the morph before it lands — the case corpus never does + that, because every case waits for the previous step to finish. +

+ +
+ + {format(value, kind, locale)} + +
+ +
+ + + + + + + {interrupting ? `interrupting ${duration}ms` : "settles"} + +
+
+ ); +} From 58ec04ae2ee32502fd3f2740b536715dcce07d8b Mon Sep 17 00:00:00 2001 From: Lochie Axon Date: Wed, 2 Sep 2026 01:55:32 +1000 Subject: [PATCH 06/10] more demos + fixes --- packages/test-cases/src/cases.ts | 115 +++++- packages/test-cases/src/index.ts | 3 + packages/test-cases/src/number-cases.ts | 2 +- packages/test-cases/src/types.ts | 3 +- packages/test-cases/src/verify.ts | 68 ++++ packages/torph/package.json | 1 + packages/torph/src/index.ts | 12 +- .../torph/src/lib/number-morph/controller.ts | 45 --- packages/torph/src/lib/number-morph/index.ts | 336 ----------------- packages/torph/src/lib/number-morph/types.ts | 6 - .../lib/text-morph/__tests__/engine.test.ts | 354 ++++++++++++++++++ .../torph/src/lib/text-morph/controller.ts | 14 +- packages/torph/src/lib/text-morph/index.ts | 141 +++++-- packages/torph/src/lib/text-morph/types.ts | 7 + .../utils/__tests__/number-cases.test.ts} | 2 +- .../number-corpus-integrity.test.ts} | 2 +- .../utils/__tests__/number-segment.test.ts} | 2 +- .../torph/src/lib/text-morph/utils/diff.ts | 237 +++++++++--- .../utils/number-animate.ts} | 2 +- .../segment.ts => text-morph/utils/number.ts} | 141 +++++-- .../torph/src/lib/text-morph/utils/segment.ts | 76 +++- packages/torph/src/lib/utils/constants.ts | 1 + packages/torph/src/lib/utils/dom.ts | 12 +- packages/torph/src/lib/utils/styles.ts | 87 +++++ packages/torph/src/lib/utils/types.ts | 10 + packages/torph/src/react/NumberMorph.tsx | 92 ----- packages/torph/src/react/TextMorph.tsx | 34 +- packages/torph/src/react/index.ts | 3 - packages/torph/src/svelte/TextMorph.svelte | 3 +- packages/torph/src/vue/TextMorph.ts | 3 + pnpm-lock.yaml | 71 +++- .../src/surfaces/homepage/examples/number.tsx | 4 +- .../surfaces/playground/chart-demo/index.tsx | 4 +- site/src/surfaces/playground/config.ts | 2 +- site/src/surfaces/playground/index.tsx | 2 +- .../surfaces/playground/input-demo/index.tsx | 4 +- .../playground/input-demo/input/index.tsx | 6 +- site/src/surfaces/playground/issue.ts | 4 +- .../src/surfaces/playground/number-detail.tsx | 6 +- .../surfaces/playground/number-sandbox.tsx | 6 +- site/src/surfaces/playground/number-tests.ts | 2 +- site/src/surfaces/playground/ticker-demo.tsx | 6 +- 42 files changed, 1272 insertions(+), 659 deletions(-) delete mode 100644 packages/torph/src/lib/number-morph/controller.ts delete mode 100644 packages/torph/src/lib/number-morph/index.ts delete mode 100644 packages/torph/src/lib/number-morph/types.ts create mode 100644 packages/torph/src/lib/text-morph/__tests__/engine.test.ts rename packages/torph/src/lib/{number-morph/__tests__/cases.test.ts => text-morph/utils/__tests__/number-cases.test.ts} (92%) rename packages/torph/src/lib/{number-morph/__tests__/corpus-integrity.test.ts => text-morph/utils/__tests__/number-corpus-integrity.test.ts} (98%) rename packages/torph/src/lib/{number-morph/__tests__/segment.test.ts => text-morph/utils/__tests__/number-segment.test.ts} (98%) rename packages/torph/src/lib/{number-morph/animate.ts => text-morph/utils/number-animate.ts} (97%) rename packages/torph/src/lib/{number-morph/segment.ts => text-morph/utils/number.ts} (60%) delete mode 100644 packages/torph/src/react/NumberMorph.tsx diff --git a/packages/test-cases/src/cases.ts b/packages/test-cases/src/cases.ts index 8eee298..248349a 100644 --- a/packages/test-cases/src/cases.ts +++ b/packages/test-cases/src/cases.ts @@ -5,7 +5,9 @@ import { verifyCharMorph, verifyCycleStability, verifyGraphemeMorph, + verifyKinds, verifyNoMorph, + verifyTextPlaces, verifyWordAbsent, verifyWordPersistence, } from "./verify"; @@ -215,14 +217,119 @@ export const CASES: TestCase[] = [ }, }, { - label: "Numbers", + label: "Numbers morph by place", description: - "Shared digits and symbols ($, commas) persist. New digits enter.", - tags: ["char morph"], + "A numeric word goes to place matching, not character matching. The comma slides one group along and the affix holds; the leading 1 does not stay a leading 1, because it is a different magnitude now.", + tags: ["number", "place"], values: ["$1,234", "$12,345,678", "$99"], align: "right", verify: (t) => - verifyGraphemeMorph(t, "$1,234", "$12,345,678", ["$", "1", ","]), + verifyTextPlaces(t, "$1,234", "$12,345,678", [ + [0, 0], + [1, null], + [7, 2], + ]), + }, + { + label: "Number inside a sentence", + description: + "The figure morphs by place while the words around it hold their identity — the units digit stays put as the count grows a tens column.", + tags: ["number", "place"], + values: ["3 unread messages", "13 unread messages", "9 unread messages"], + verify: (t) => + verifyTextPlaces(t, "3 unread messages", "13 unread messages", [ + [0, null], + [1, 0], + [3, 2], + [5, 4], + ]), + }, + { + label: "Digits and symbols are told apart", + description: + "Kinds drive the animation: digits slide down into place, the symbols around them slide up. Everything else stays text.", + tags: ["number"], + values: ["$1,234", "$5,678"], + verify: (t) => + verifyKinds(t, "$1,234", [ + "symbol", + "digit", + "symbol", + "digit", + "digit", + "digit", + ]), + }, + { + label: "Version strings stay text", + description: + "A token has to be a quantity all the way through to morph as one. \"v1.2.3\" has no units column, so it morphs character by character like any other word.", + tags: ["number"], + values: ["v1.2.3", "v1.3.0", "v2.0.0"], + verify: (t) => verifyKinds(t, "v1.2.3", new Array(6).fill(undefined)), + }, + { + label: "Two numbers, one sentence", + description: + "The second figure pairs with the second figure, not with the first. Both numbers stand in for each other during the word-level match, so their order in the sentence is what carries them across.", + tags: ["number", "place"], + values: ["2 of 10 done", "2 of 15 done", "7 of 15 done"], + verify: (t) => + verifyTextPlaces(t, "2 of 10 done", "2 of 15 done", [ + [0, 0], + [4, 4], + [5, null], + [7, 7], + ]), + }, + { + label: "Emptying a number to its affix", + description: + "Backspacing the last digit out of \"$4\" leaves a token with no digits left to be a number by. The dollar sign is still the same dollar sign, so it holds rather than re-entering.", + tags: ["number", "exit"], + values: ["$4", "$", "$4", "$420"], + verify: (t) => + combineResults( + verifyTextPlaces(t, "$4", "$", [[0, 0]]), + verifyTextPlaces(t, "$", "$420", [[0, 0]]), + ), + }, + { + label: "A number never claims a word", + description: + "\"5\" and \"five\" are the same quantity and share no characters, so the digit leaves and the word arrives. Spelling is the only thing the diff can see.", + tags: ["number"], + values: ["5 items", "five items"], + verify: (t) => + combineResults( + verifyTextPlaces(t, "5 items", "five items", [ + [0, null], + [2, 2], + ]), + verifyWordPersistence(t, "5 items", "five items", "items"), + ), + }, + { + label: "Affixes hold while digits churn", + description: + "Brackets, currency symbols and group separators are the still part of a number. Every digit can change under them without any of them moving.", + tags: ["number", "place"], + values: ["(1,234)", "(5,678)", "12%", "97%"], + align: "right", + verify: (t) => + verifyTextPlaces(t, "(1,234)", "(5,678)", [ + [0, 0], + [2, 2], + [6, 6], + ]), + }, + { + label: "Dates stay text", + description: + "Same rule, and the one that matters most for a default: a hyphen is not a group separator, so a date is never mistaken for a number.", + tags: ["number"], + values: ["2024-01-01", "2024-02-01"], + verify: (t) => verifyKinds(t, "2024-01-01", new Array(10).fill(undefined)), }, { label: "Long word char morph", diff --git a/packages/test-cases/src/index.ts b/packages/test-cases/src/index.ts index b3de75a..3609e54 100644 --- a/packages/test-cases/src/index.ts +++ b/packages/test-cases/src/index.ts @@ -3,10 +3,13 @@ export { ALL_NUMBER_TAGS, NUMBER_CASES } from "./number-cases"; export { combineResults, renderSegments, + textAlignment, verifyCharMorph, verifyCycleStability, verifyGraphemeMorph, + verifyKinds, verifyNoMorph, + verifyTextPlaces, verifyWordAbsent, verifyWordPersistence, } from "./verify"; diff --git a/packages/test-cases/src/number-cases.ts b/packages/test-cases/src/number-cases.ts index 28201c2..d5be68d 100644 --- a/packages/test-cases/src/number-cases.ts +++ b/packages/test-cases/src/number-cases.ts @@ -173,7 +173,7 @@ export const NUMBER_CASES: NumberCase[] = [ { label: "Locale formatting", description: - "Raw numbers formatted by NumberMorph itself. Grouping follows the locale, so the same value reads differently per step.", + "Raw numbers formatted by TextMorph itself. Grouping follows the locale, so the same value reads differently per step.", tags: ["locale"], values: [1234567.891, 9876543.21], locale: "de-DE", diff --git a/packages/test-cases/src/types.ts b/packages/test-cases/src/types.ts index 4360c91..a66df49 100644 --- a/packages/test-cases/src/types.ts +++ b/packages/test-cases/src/types.ts @@ -1,6 +1,7 @@ export type Segment = { id: string; string: string; + kind?: "digit" | "symbol"; }; export type DiffResult = { @@ -59,7 +60,7 @@ export type NumberCase = { label: string; description: string; tags: string[]; - /** Rendered through `NumberMorph`; numbers are formatted by it, strings are not. */ + /** Rendered through `TextMorph`; numbers are formatted by it, strings are not. */ values: (string | number)[]; /** * Caret position for each value, switching that step from place matching to diff --git a/packages/test-cases/src/verify.ts b/packages/test-cases/src/verify.ts index b935f17..354cf85 100644 --- a/packages/test-cases/src/verify.ts +++ b/packages/test-cases/src/verify.ts @@ -128,3 +128,71 @@ export function combineResults(...results: Result[]): Result { detail: results.map((r) => r.detail).join("; "), }; } + +/** + * Where each character of `to` came from in `from`, by index — `null` for a + * character that entered rather than persisted. + * + * The number-side twin of this lives in `number-verify`, but it addresses + * `segmentNumber` directly. This one goes the whole way round through + * `segmentText` and `diffSegments`, which is the only way to assert that the + * text pipeline hands its numeric words to place matching at all. + */ +export function textAlignment( + t: TorphApi, + from: string, + to: string, +): (number | null)[] { + const old = t.segmentText(from, L); + const { segments } = t.diffSegments(old, to, L); + const positions = new Map(old.map((segment, i) => [segment.id, i])); + + return segments.map((segment) => positions.get(segment.id) ?? null); +} + +/** Individual `[newIndex, oldIndex]` origins through the text pipeline. */ +export function verifyTextPlaces( + t: TorphApi, + from: string, + to: string, + pairs: [newIndex: number, oldIndex: number | null][], +): Result { + const places = textAlignment(t, from, to); + const wrong = pairs.filter(([newIndex, oldIndex]) => places[newIndex] !== oldIndex); + + return { + pass: wrong.length === 0, + detail: wrong.length + ? wrong + .map( + ([newIndex, oldIndex]) => + `"${to[newIndex]}" at ${newIndex} should come from ${ + oldIndex === null ? "nowhere" : oldIndex + }, came from ${places[newIndex] ?? "nowhere"}`, + ) + .join("; ") + : `${pairs.length} place${pairs.length === 1 ? "" : "s"} held`, + }; +} + +/** Every segment of `value` that belongs to a number carries a kind. */ +export function verifyKinds( + t: TorphApi, + value: string, + expected: (string | undefined)[], +): Result { + const kinds = t.segmentText(value, L).map((s) => s.kind); + const pass = + kinds.length === expected.length && + kinds.every((kind, i) => kind === expected[i]); + + const render = (list: (string | undefined)[]) => + `[${list.map((k) => k ?? "text").join(",")}]`; + + return { + pass, + detail: pass + ? `${render(kinds)} as expected` + : `expected ${render(expected)}, got ${render(kinds)}`, + }; +} diff --git a/packages/torph/package.json b/packages/torph/package.json index b39b685..020efb5 100644 --- a/packages/torph/package.json +++ b/packages/torph/package.json @@ -98,6 +98,7 @@ "eslint-plugin-react": "^7.33.2", "eslint-plugin-react-hooks": "^5.2.0", "globals": "^17.9.0", + "happy-dom": "^20.12.0", "prettier": "^3.0.3", "react": "^18.2.0", "react-dom": "^18.2.0", diff --git a/packages/torph/src/index.ts b/packages/torph/src/index.ts index f1c0369..c4ae2cd 100644 --- a/packages/torph/src/index.ts +++ b/packages/torph/src/index.ts @@ -9,9 +9,11 @@ export type { SpringParams } from "./lib/utils/spring"; export { segmentText } from "./lib/text-morph/utils/segment"; export type { Segment } from "./lib/text-morph/utils/segment"; export { diffSegments } from "./lib/text-morph/utils/diff"; -export type { DiffResult } from "./lib/text-morph/utils/diff"; +export type { DiffOptions, DiffResult } from "./lib/text-morph/utils/diff"; -export { DEFAULT_NUMBER_MORPH_OPTIONS, NumberMorph } from "./lib/number-morph"; -export type { NumberMorphOptions } from "./lib/number-morph/types"; -export { decimalSeparator, segmentNumber } from "./lib/number-morph/segment"; -export type { NumberSegment } from "./lib/number-morph/segment"; +export { + decimalSeparator, + isNumericWord, + segmentNumber, +} from "./lib/text-morph/utils/number"; +export type { NumberSegment } from "./lib/text-morph/utils/number"; diff --git a/packages/torph/src/lib/number-morph/controller.ts b/packages/torph/src/lib/number-morph/controller.ts deleted file mode 100644 index 10e2572..0000000 --- a/packages/torph/src/lib/number-morph/controller.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { NumberMorph } from "./index"; -import type { NumberMorphOptions } from "./types"; - -export class NumberMorphController { - private instance: NumberMorph | null = null; - private lastValue: number | string = ""; - private lastCursorIndex?: number; - private configKey = ""; - - attach(element: HTMLElement, options: Omit) { - this.instance?.destroy(); - this.instance = new NumberMorph({ element, ...options }); - this.configKey = NumberMorphController.serializeConfig(options); - - if (this.lastValue !== "") { - this.instance.update(this.lastValue, this.lastCursorIndex); - } - } - - update(value: number | string, cursorIndex?: number) { - this.lastValue = value; - this.lastCursorIndex = cursorIndex; - this.instance?.update(value, cursorIndex); - } - - needsRecreate(options: Omit): boolean { - return NumberMorphController.serializeConfig(options) !== this.configKey; - } - - destroy() { - this.instance?.destroy(); - this.instance = null; - } - - static serializeConfig(options: Omit): string { - return JSON.stringify({ - ease: options.ease, - duration: options.duration, - locale: options.locale, - decimals: options.decimals, - disabled: options.disabled, - respectReducedMotion: options.respectReducedMotion, - }); - } -} diff --git a/packages/torph/src/lib/number-morph/index.ts b/packages/torph/src/lib/number-morph/index.ts deleted file mode 100644 index 4efb902..0000000 --- a/packages/torph/src/lib/number-morph/index.ts +++ /dev/null @@ -1,336 +0,0 @@ -import type { NumberMorphOptions } from "./types"; -import { - type NumberSegment, - decimalSeparator, - segmentNumber, -} from "./segment"; -import { - animateNumberExit, - animateNumberEnter, - animateNumberPersist, -} from "./animate"; -import { resolveEase } from "../utils/spring"; -import { BASE_DEFAULTS } from "../utils/types"; -import { - type Measures, - measure, - computeDelta, - findNearestAnchor, - resolveExitingAnchors, -} from "../utils/flip"; -import { - clearContainerTransition, - transitionContainerSize, -} from "../utils/animate"; -import { detachFromFlow, reconcileChildren } from "../utils/dom"; -import { addStyles, removeStyles } from "../utils/styles"; -import { - ATTR_ROOT, - ATTR_ID, - ATTR_EXITING, -} from "../utils/constants"; -import { - type ReducedMotionState, - createReducedMotionListener, -} from "../utils/reduced-motion"; - -export type { NumberMorphOptions } from "./types"; -export type { NumberSegment } from "./segment"; -export { NumberMorphController } from "./controller"; - -export const DEFAULT_NUMBER_MORPH_OPTIONS = { - ...BASE_DEFAULTS, -} as const satisfies Omit; - -const MASK_PROPERTIES = ["mask-image", "mask-repeat", "mask-clip"] as const; - -export class NumberMorph { - private element: HTMLElement; - private duration: number; - private ease: string; - private locale: string; - private decimalChar: string; - private decimals?: number; - private disabled: boolean; - private onAnimationStart?: () => void; - private onAnimationComplete?: () => void; - - private currentValue = ""; - private prevMeasures: Measures = {}; - private currentMeasures: Measures = {}; - private currentSegments: NumberSegment[] = []; - private isInitialRender = true; - private reducedMotion: ReducedMotionState | null = null; - - constructor(options: NumberMorphOptions) { - const opts = { ...DEFAULT_NUMBER_MORPH_OPTIONS, ...options }; - const { ease, duration } = resolveEase(opts.ease, opts.duration!); - - this.element = opts.element; - this.duration = duration; - this.ease = ease; - this.locale = opts.locale!; - this.decimalChar = decimalSeparator(this.locale); - this.decimals = opts.decimals; - this.disabled = opts.disabled!; - this.onAnimationStart = opts.onAnimationStart; - this.onAnimationComplete = opts.onAnimationComplete; - - if (opts.respectReducedMotion) { - this.reducedMotion = createReducedMotionListener(); - } - - if (!this.isDisabled()) { - this.element.setAttribute(ATTR_ROOT, ""); - this.clipBlockAxis(); - this.fadeBlockEdges(); - addStyles(); - } - } - - destroy() { - this.reducedMotion?.destroy(); - clearContainerTransition(this.element); - this.element.getAnimations().forEach((anim) => anim.cancel()); - this.element.removeAttribute(ATTR_ROOT); - this.element.style.overflow = ""; - this.element.style.overflowX = ""; - this.element.style.overflowY = ""; - MASK_PROPERTIES.forEach((property) => { - this.element.style.removeProperty(property); - this.element.style.removeProperty(`-webkit-${property}`); - }); - removeStyles(); - } - - /** - * Digits slide vertically past the line box on enter and exit, so the block - * axis is masked. The inline axis must stay visible: the container spends the - * whole duration animating to its new width, and clipping it would mask every - * character sitting beyond the old width until the size transition catches up. - * - * `clip` is what makes that split legal — `overflow-x: visible` next to - * `overflow-y: hidden` computes to `auto`, which would scroll instead. - */ - private clipBlockAxis() { - if (CSS.supports("overflow", "clip")) { - this.element.style.overflowX = "visible"; - this.element.style.overflowY = "clip"; - } else { - this.element.style.overflow = "hidden"; - } - } - - /** - * Softens the block-axis clip into a gradient, so characters dissolve across - * the edge of the line box instead of meeting a hard line. Positional rather - * than timed: how faint a character is depends on where it has slid to, which - * keeps it in step with its own movement at any duration. - * - * The band is `--torph-fade` on the root — set it to `0` for a hard edge. - * - * `no-clip` is load-bearing. A mask layer is otherwise clipped to the border - * box, which would hide every character sitting beyond the container's - * animating width — exactly what the visible inline axis exists to show. - * `repeat-x` then carries the same profile across that overflow, while the - * block axis stays a single tile so anything above or below the box is masked - * out. Without `no-clip` the mask would cost more than it gives, so the hard - * clip stands in. - */ - private fadeBlockEdges() { - if ( - !CSS.supports("mask-clip", "no-clip") && - !CSS.supports("-webkit-mask-clip", "no-clip") - ) { - return; - } - - const fade = "var(--torph-fade, 0.15em)"; - - this.setMaskProperty( - "mask-image", - `linear-gradient(to bottom, transparent, #000 ${fade}, #000 calc(100% - ${fade}), transparent)`, - ); - this.setMaskProperty("mask-repeat", "repeat-x"); - this.setMaskProperty("mask-clip", "no-clip"); - } - - private setMaskProperty(property: string, value: string) { - this.element.style.setProperty(property, value); - this.element.style.setProperty(`-webkit-${property}`, value); - } - - private isDisabled(): boolean { - return Boolean( - this.disabled || this.reducedMotion?.prefersReducedMotion, - ); - } - - update(value: number | string, cursorIndex?: number) { - const formatted = - typeof value === "number" - ? value.toLocaleString(this.locale, { - minimumFractionDigits: this.decimals, - maximumFractionDigits: this.decimals, - }) - : value; - - if (formatted === this.currentValue) return; - this.currentValue = formatted; - - if (this.isDisabled()) { - this.element.textContent = formatted; - return; - } - - if (!this.isInitialRender && this.onAnimationStart) { - this.onAnimationStart(); - } - - const segments = segmentNumber( - formatted, - this.currentSegments, - cursorIndex, - this.decimalChar, - ); - this.animate(segments); - } - - private animate(segments: NumberSegment[]) { - const element = this.element; - // Subpixel, to match what transitionContainerSize measures the new size with - const oldRect = element.getBoundingClientRect(); - const oldWidth = oldRect.width; - const oldHeight = oldRect.height; - const slideDistance = element.offsetHeight || 20; - - this.prevMeasures = measure(element); - const oldChildren = Array.from(element.children) as HTMLElement[]; - const newIds = new Set(segments.map((s) => s.id)); - - const exiting = oldChildren.filter( - (child) => - !newIds.has(child.getAttribute(ATTR_ID) as string) && - !child.hasAttribute(ATTR_EXITING), - ); - const exitingSet = new Set(exiting); - const oldIds = oldChildren.map( - (c) => c.getAttribute(ATTR_ID) as string, - ); - - const exitingAnchorId = resolveExitingAnchors( - oldChildren, - exitingSet, - oldIds, - newIds, - ); - - detachFromFlow(element, exiting); - reconcileChildren(element, oldChildren, newIds, segments); - - this.currentMeasures = measure(element); - - // Frame-0 positions have to be measured with the container still at its old - // width. The root inherits text-align, so under centre/right alignment a - // digit's layout position depends on the container width, and the container - // does not reach its new width until the size transition finishes. - element.style.width = `${oldWidth}px`; - void element.offsetWidth; - const firstFrameMeasures = measure(element); - element.style.width = "auto"; - - this.currentSegments = segments; - - exiting.forEach((child) => { - if (this.isInitialRender) { - child.remove(); - return; - } - - const anchorId = exitingAnchorId.get(child); - const { dx, dy } = anchorId - ? computeDelta(this.currentMeasures, this.prevMeasures, anchorId) - : { dx: 0, dy: 0 }; - - animateNumberExit(child, { - dx, - dy, - slideDistance, - duration: this.duration, - ease: this.ease, - }); - }); - - if (this.isInitialRender) { - this.isInitialRender = false; - element.style.width = "auto"; - element.style.height = "auto"; - return; - } - - this.animateChildren(segments, slideDistance, firstFrameMeasures); - - transitionContainerSize( - element, - oldWidth, - oldHeight, - this.duration, - this.ease, - this.onAnimationComplete, - ); - } - - private animateChildren( - segments: NumberSegment[], - slideDistance: number, - firstFrameMeasures: Measures, - ) { - const segmentIds = segments.map((s) => s.id); - const persistentIds = new Set( - segmentIds.filter((id) => this.prevMeasures[id]), - ); - const kindMap = new Map(segments.map((s) => [s.id, s.kind])); - - const children = Array.from(this.element.children) as HTMLElement[]; - children.forEach((child, index) => { - if (child.hasAttribute(ATTR_EXITING)) return; - - const key = child.getAttribute(ATTR_ID) || `child-${index}`; - const isNew = !this.prevMeasures[key]; - - if (isNew) { - const anchorKey = findNearestAnchor( - segments.findIndex((s) => s.id === key), - segmentIds, - persistentIds, - ); - - const { dx: deltaX, dy: deltaY } = anchorKey - ? computeDelta(this.prevMeasures, firstFrameMeasures, anchorKey) - : { dx: 0, dy: 0 }; - - animateNumberEnter(child, { - deltaX, - deltaY, - slideDistance, - kind: kindMap.get(key) ?? "digit", - duration: this.duration, - ease: this.ease, - }); - } else { - const { dx: deltaX, dy: deltaY } = computeDelta( - this.prevMeasures, - firstFrameMeasures, - key, - ); - - animateNumberPersist(child, { - deltaX, - deltaY, - duration: this.duration, - ease: this.ease, - }); - } - }); - } -} diff --git a/packages/torph/src/lib/number-morph/types.ts b/packages/torph/src/lib/number-morph/types.ts deleted file mode 100644 index 5a0c91b..0000000 --- a/packages/torph/src/lib/number-morph/types.ts +++ /dev/null @@ -1,6 +0,0 @@ -import type { BaseMorphOptions } from "../utils/types"; - -export interface NumberMorphOptions extends BaseMorphOptions { - locale?: string; - decimals?: number; -} diff --git a/packages/torph/src/lib/text-morph/__tests__/engine.test.ts b/packages/torph/src/lib/text-morph/__tests__/engine.test.ts new file mode 100644 index 0000000..5755346 --- /dev/null +++ b/packages/torph/src/lib/text-morph/__tests__/engine.test.ts @@ -0,0 +1,354 @@ +// @vitest-environment happy-dom +import { describe, it, expect, afterEach, beforeAll } from "vitest"; +import { TextMorph } from "../index"; +import type { TextMorphOptions } from "../types"; +import { ATTR_EXITING, ATTR_ID, ATTR_KIND } from "../../utils/constants"; + +// The segmentation and diff suites assert what a morph *should* be. This one +// asserts what the engine does with that: which of the two animation families +// each character is handed to, and whether the root is masked while any of them +// is sliding. Nothing here needs real geometry — happy-dom reports every rect as +// zero, which pins every FLIP delta to zero and leaves the slide offsets as the +// only motion in the transforms. That is exactly the part under test. + +const SLIDE = 20; // `offsetHeight || 20` — happy-dom has no layout + +/** + * The engine cancels animations constantly — every interrupted morph does. A + * browser leaves the `finished` promise of a cancelled animation internally + * handled; happy-dom rejects it for real, so without this every cancel lands as + * an unhandled AbortError and buries the actual output. + */ +beforeAll(() => { + const original = Element.prototype.animate; + Element.prototype.animate = function ( + this: Element, + keyframes: unknown, + options: unknown, + ) { + const animation = original.call(this, keyframes as never, options as never); + animation.finished?.catch(() => {}); + return animation; + } as typeof Element.prototype.animate; +}); + +type Recorded = { id: string | null; keyframes: unknown; options: unknown }; + +function recordAnimations() { + const calls: Recorded[] = []; + const original = Element.prototype.animate; + + Element.prototype.animate = function ( + this: Element, + keyframes: unknown, + options: unknown, + ) { + calls.push({ id: this.getAttribute(ATTR_ID), keyframes, options }); + return original.call(this, keyframes as never, options as never); + } as typeof Element.prototype.animate; + + return { calls, restore: () => (Element.prototype.animate = original) }; +} + +type Frame = { transform?: string; opacity?: number; offset?: number }; + +/** + * The transform an element was given, flattened to one comparable string. + * + * The two families are distinguishable by shape alone: a slide is a single + * keyframe pinned to one end of the timeline, a text morph is a pair walking + * from an offset to `none`. + */ +function motion(calls: Recorded[], id: string): string | null { + const call = calls.find((c) => { + if (c.id !== id) return false; + const frames = (Array.isArray(c.keyframes) ? c.keyframes : [c.keyframes]) as Frame[]; + return frames.some((frame) => frame.transform !== undefined); + }); + if (!call) return null; + + if (Array.isArray(call.keyframes)) { + const [from, to] = call.keyframes as Frame[]; + return `${from!.transform} → ${to!.transform}`; + } + const frame = call.keyframes as Frame; + return `${frame.transform} @${frame.offset}`; +} + +const slideFrom = (dy: number) => `translate(0px, ${dy}px) @0`; +const slideOut = `translate(0px, ${SLIDE}px) @1`; +const textEnter = "translate(0px, 0px) scale(0.95) → none"; +const textPersist = "translate(0px, 0px) scale(1) → none"; +const textExit = "translate(0px, 0px) scale(0.95) @1"; + +type Child = { id: string; text: string; kind: string | null; exiting: boolean }; + +function children(element: HTMLElement): Child[] { + return Array.from(element.children).map((child) => ({ + id: child.getAttribute(ATTR_ID)!, + text: child.textContent ?? "", + kind: child.getAttribute(ATTR_KIND), + exiting: child.hasAttribute(ATTR_EXITING), + })); +} + +const live = (element: HTMLElement) => children(element).filter((c) => !c.exiting); +const leaving = (element: HTMLElement) => children(element).filter((c) => c.exiting); + +/** What the root reads as, ignoring the characters on their way out. */ +const rendered = (element: HTMLElement) => + live(element) + .map((c) => c.text.replace(/\u00A0/g, " ")) + .join(""); + +const shape = (element: HTMLElement) => + live(element).map((c) => `${c.text}:${c.kind ?? "text"}`); + +const idOf = (element: HTMLElement, text: string) => + live(element).find((c) => c.text === text)!.id; + +const mounted: TextMorph[] = []; + +function mount(options: Partial = {}) { + const element = document.createElement("span"); + document.body.appendChild(element); + // Pinned off: the listener would otherwise decide the outcome from the + // environment's media query rather than from the value under test. + const morph = new TextMorph({ + element, + respectReducedMotion: false, + ...options, + }); + mounted.push(morph); + return { element, morph }; +} + +afterEach(() => { + while (mounted.length) mounted.pop()!.destroy(); + document.body.innerHTML = ""; +}); + +describe("kinds reach the DOM", () => { + it("marks digits and the symbols around them, and nothing else", () => { + const { element, morph } = mount(); + morph.update("$1,234"); + + expect(shape(element)).toEqual([ + "$:symbol", + "1:digit", + ",:symbol", + "2:digit", + "3:digit", + "4:digit", + ]); + }); + + it("leaves a sentence's words alone", () => { + const { element, morph } = mount(); + morph.update("3 unread messages"); + + expect(shape(element)).toEqual([ + "3:digit", + " :text", + "unread:text", + " :text", + "messages:text", + ]); + }); +}); + +describe("animation dispatch", () => { + it("slides a new digit down and a new symbol up", () => { + const { element, morph } = mount(); + morph.update("1234"); + + const { calls, restore } = recordAnimations(); + morph.update("1,234"); + restore(); + + const comma = idOf(element, ","); + const leading = live(element)[0]!.id; + + // Digits arrive from above and symbols from below, so a separator appearing + // between them reads as a different event from the digit that displaced it. + expect(motion(calls, leading)).toBe(slideFrom(-SLIDE)); + expect(motion(calls, comma)).toBe(slideFrom(SLIDE)); + }); + + it("leaves a digit that held its place untouched", () => { + const { element, morph } = mount(); + morph.update("1234"); + const held = live(element).map((c) => c.id).slice(1); + + const { calls, restore } = recordAnimations(); + morph.update("1,234"); + restore(); + + // Place matching keeps 2, 3 and 4 in their columns. With no delta to + // correct, animating them at all would be motion the number does not have. + for (const id of held) expect(motion(calls, id)).toBeNull(); + }); + + it("sends words through the text morph, not the slide", () => { + const { element, morph } = mount(); + morph.update("hello world"); + + const { calls, restore } = recordAnimations(); + morph.update("hello there"); + restore(); + + expect(motion(calls, idOf(element, "hello"))).toBe(textPersist); + expect(motion(calls, idOf(element, "there"))).toBe(textEnter); + }); + + it("dispatches exits on the kind the element left with", () => { + const { element, morph } = mount(); + morph.update("$5 hello"); + const digit = idOf(element, "5"); + const word = idOf(element, "hello"); + + const { calls, restore } = recordAnimations(); + morph.update("$5"); + restore(); + + expect(leaving(element).map((c) => c.text)).toContain("hello"); + expect(motion(calls, word)).toBe(textExit); + expect(motion(calls, digit)).toBeNull(); // held its place, never left + }); + + it("slides a departing digit out", () => { + const { element, morph } = mount(); + morph.update("42"); + const digits = live(element).map((c) => c.id); + + const { calls, restore } = recordAnimations(); + morph.update("hello"); + restore(); + + for (const id of digits) expect(motion(calls, id)).toBe(slideOut); + }); +}); + +describe("the block-axis mask", () => { + it("is installed only once a value holds a number, and held until it stops", () => { + const { element, morph } = mount(); + + morph.update("hello"); + expect(element.style.overflowY).toBe(""); + expect(element.style.getPropertyValue("mask-image")).toBe(""); + + morph.update("5 apples"); + expect(element.style.overflowY).toBe("clip"); + expect(element.style.getPropertyValue("mask-image")).toContain("--torph-fade"); + + // The digit is mid-exit on this update — dropping the mask now would let it + // slide out in full view. + morph.update("hello"); + expect(element.style.overflowY).toBe("clip"); + + morph.update("goodbye"); + expect(element.style.overflowY).toBe(""); + expect(element.style.getPropertyValue("mask-image")).toBe(""); + }); + + it("is cleared on destroy", () => { + const { element, morph } = mount(); + morph.update("$5"); + expect(element.style.overflowY).toBe("clip"); + + morph.destroy(); + mounted.pop(); + + expect(element.style.overflowY).toBe(""); + expect(element.style.getPropertyValue("mask-image")).toBe(""); + }); +}); + +describe("opting out", () => { + it("numbers: false leaves digits as text and never masks", () => { + const { element, morph } = mount({ numbers: false }); + morph.update("hello"); + morph.update("$1,234"); + + expect(shape(element).every((entry) => entry.endsWith(":text"))).toBe(true); + expect(element.style.overflowY).toBe(""); + }); + + it("a multi-line value falls back to text", () => { + const { element, morph } = mount(); + morph.update("Total"); + morph.update("Total\n1,234"); + + expect(shape(element).some((entry) => entry.includes(":digit"))).toBe(false); + expect(element.style.overflowY).toBe(""); + }); +}); + +describe("numeric values", () => { + it("formats through locale and decimals", () => { + const { element, morph } = mount({ locale: "en", decimals: 2 }); + morph.update(1234.5); + + expect(rendered(element)).toBe("1,234.50"); + }); + + it("takes a caret for a value that is a single number", () => { + const { element, morph } = mount(); + morph.update("$4"); + const dollar = idOf(element, "$"); + const four = idOf(element, "4"); + + // Typing "2" after the 4: place matching would read the 4 as having changed + // magnitude, the caret says it simply stayed where it was. + morph.update("$42", 3); + + const after = live(element); + expect(after.map((c) => c.id)).toEqual([dollar, four, after[2]!.id]); + expect(rendered(element)).toBe("$42"); + }); +}); + +describe("invariants across a chained morph", () => { + it("never gives two live children the same ID, and always renders the value", () => { + const { element, morph } = mount(); + const sequence = [ + "$4", + "$", + "$420", + "$4,020", + "hello world", + "3 unread messages", + "13 unread items", + "Total\n1,234", + "0", + "1,000,000", + "it cost $1,234.", + "", + "99%", + ]; + + for (const value of sequence) { + morph.update(value); + + const ids = live(element).map((c) => c.id); + const duplicate = ids.find((id, i) => ids.indexOf(id) !== i); + expect(duplicate, `"${value}" repeats ID ${duplicate}`).toBeUndefined(); + + // An empty value keeps a zero-width space so the line box survives the + // exits, so it is the one step that does not render its own text. + if (value !== "") { + expect(rendered(element).replace(/\n/g, "")).toBe(value.replace(/\n/g, "")); + } + } + }); +}); + +describe("disabled", () => { + it("writes the value straight to the element", () => { + const { element, morph } = mount({ disabled: true }); + morph.update("$1,234"); + + expect(element.textContent).toBe("$1,234"); + expect(element.children.length).toBe(0); + }); +}); diff --git a/packages/torph/src/lib/text-morph/controller.ts b/packages/torph/src/lib/text-morph/controller.ts index 616dcf6..83c9216 100644 --- a/packages/torph/src/lib/text-morph/controller.ts +++ b/packages/torph/src/lib/text-morph/controller.ts @@ -3,7 +3,8 @@ import type { TextMorphOptions } from "./types"; export class MorphController { private instance: TextMorph | null = null; - private lastText = ""; + private lastText: string | number = ""; + private lastCursorIndex?: number; private configKey = ""; attach(element: HTMLElement, options: Omit) { @@ -11,14 +12,15 @@ export class MorphController { this.instance = new TextMorph({ element, ...options }); this.configKey = MorphController.serializeConfig(options); - if (this.lastText) { - this.instance.update(this.lastText); + if (this.lastText !== "") { + this.instance.update(this.lastText, this.lastCursorIndex); } } - update(text: string) { + update(text: string | number, cursorIndex?: number) { this.lastText = text; - this.instance?.update(text); + this.lastCursorIndex = cursorIndex; + this.instance?.update(text, cursorIndex); } needsRecreate(options: Omit): boolean { @@ -36,6 +38,8 @@ export class MorphController { duration: options.duration, locale: options.locale, scale: options.scale, + numbers: options.numbers, + decimals: options.decimals, debug: options.debug, disabled: options.disabled, respectReducedMotion: options.respectReducedMotion, diff --git a/packages/torph/src/lib/text-morph/index.ts b/packages/torph/src/lib/text-morph/index.ts index bb6a5ce..bf52572 100644 --- a/packages/torph/src/lib/text-morph/index.ts +++ b/packages/torph/src/lib/text-morph/index.ts @@ -2,6 +2,7 @@ import type { TextMorphOptions } from "./types"; import { BASE_DEFAULTS, type Segment } from "../utils/types"; import { resolveEase } from "../utils/spring"; import { segmentText } from "./utils/segment"; +import { numbersAllowed } from "./utils/number"; import { type Measures, measure, @@ -15,14 +16,25 @@ import { transitionContainerSize, } from "../utils/animate"; import { animateExit, animateEnterOrPersist } from "./utils/animate"; +import { + animateNumberEnter, + animateNumberExit, + animateNumberPersist, +} from "./utils/number-animate"; import { detachFromFlow, splitWordSpans, reconcileChildren } from "../utils/dom"; import { diffSegments } from "./utils/diff"; -import { addStyles, removeStyles } from "../utils/styles"; +import { + addStyles, + applyBlockFade, + clearBlockFade, + removeStyles, +} from "../utils/styles"; import { ATTR_ROOT, ATTR_DEBUG, ATTR_EXITING, ATTR_ID, + ATTR_KIND, EMPTY_ID, } from "../utils/constants"; import { @@ -39,6 +51,7 @@ export const DEFAULT_TEXT_MORPH_OPTIONS = { ...BASE_DEFAULTS, debug: false, scale: true, + numbers: true, } as const satisfies Omit; export class TextMorph { @@ -54,6 +67,8 @@ export class TextMorph { private previousSegments: Segment[] = []; private isInitialRender = true; private reducedMotion: ReducedMotionState | null = null; + private fadeApplied = false; + private hadNumbers = false; constructor(options: TextMorphOptions) { const { ease: rawEase, ...rest } = { @@ -83,6 +98,7 @@ export class TextMorph { destroy() { this.reducedMotion?.destroy(); + clearBlockFade(this.element); clearContainerTransition(this.element); this.element.getAnimations().forEach((anim) => anim.cancel()); this.element.removeAttribute(ATTR_ROOT); @@ -96,13 +112,26 @@ export class TextMorph { ); } - update(value: HTMLElement | string) { - if (value === this.data) return; - this.data = value; + /** + * `cursorIndex` switches a value that is a single number from place matching + * to caret matching — what an editable field wants, where the character the + * user just typed is known and place value is not the point. + */ + update(value: HTMLElement | string | number, cursorIndex?: number) { + const formatted = + typeof value === "number" + ? value.toLocaleString(this.options.locale, { + minimumFractionDigits: this.options.decimals, + maximumFractionDigits: this.options.decimals, + }) + : value; + + if (formatted === this.data) return; + this.data = formatted; if (this.isDisabled()) { - if (typeof value === "string") { - this.element.textContent = value; + if (typeof formatted === "string") { + this.element.textContent = formatted; } return; } @@ -114,16 +143,24 @@ export class TextMorph { if (this.options.onAnimationStart && !this.isInitialRender) { this.options.onAnimationStart(); } - this.createTextGroup(this.data, this.element); + this.createTextGroup(this.data, this.element, cursorIndex); } } - private createTextGroup(value: string, element: HTMLElement) { + private createTextGroup( + value: string, + element: HTMLElement, + cursorIndex?: number, + ) { // Measured before a running transition is aborted below, so an interrupted // morph carries on from the size on screen rather than snapping. const oldRect = element.getBoundingClientRect(); const oldWidth = oldRect.width; const oldHeight = oldRect.height; + // The block-axis travel of a digit is one line, so the line box measures it. + const slideDistance = element.offsetHeight || 20; + + const numbers = numbersAllowed(value, this.options.numbers !== false); let segments: Segment[]; let splits: Map; @@ -133,14 +170,17 @@ export class TextMorph { this.previousSegments, value, this.options.locale!, + { numbers, cursorIndex }, ); segments = result.segments; splits = result.splits; } else { - segments = segmentText(value, this.options.locale!); + segments = segmentText(value, this.options.locale!, numbers); splits = new Map(); } + this.applyFade(segments.some((segment) => segment.kind !== undefined)); + // Keep a zero-width space segment so the container always has in-flow // content, preserving the line box height during exit animations. const isEmptyTransition = segments.length === 0; @@ -181,7 +221,7 @@ export class TextMorph { const firstFrameMeasures = measure(this.element); element.style.width = "auto"; - this.updateStyles(segments, firstFrameMeasures); + this.updateStyles(segments, firstFrameMeasures, slideDistance); exiting.forEach((child) => { if (this.isInitialRender || child.getAttribute(ATTR_ID) === EMPTY_ID) { @@ -194,13 +234,23 @@ export class TextMorph { ? computeDelta(this.currentMeasures, this.prevMeasures, anchorId) : { dx: 0, dy: 0 }; - animateExit(child, { - dx, - dy, - duration: this.options.duration!, - ease: this.options.ease!, - scale: this.options.scale!, - }); + if (child.hasAttribute(ATTR_KIND)) { + animateNumberExit(child, { + dx, + dy, + slideDistance, + duration: this.options.duration!, + ease: this.options.ease!, + }); + } else { + animateExit(child, { + dx, + dy, + duration: this.options.duration!, + ease: this.options.ease!, + scale: this.options.scale!, + }); + } }); this.previousSegments = segments; @@ -234,11 +284,32 @@ export class TextMorph { } } - private updateStyles(segments: Segment[], firstFrameMeasures: Measures) { + /** + * Held one update past the last number so digits on their way out are still + * masked while they slide, and only installed once a value actually contains + * one — a mask on every root would cost a stacking context for nothing. + */ + private applyFade(hasNumbers: boolean) { + const wanted = hasNumbers || this.hadNumbers; + this.hadNumbers = hasNumbers; + + if (wanted === this.fadeApplied) return; + this.fadeApplied = wanted; + + if (wanted) applyBlockFade(this.element); + else clearBlockFade(this.element); + } + + private updateStyles( + segments: Segment[], + firstFrameMeasures: Measures, + slideDistance: number, + ) { if (this.isInitialRender) return; const children = Array.from(this.element.children) as HTMLElement[]; const segmentIds = segments.map((b) => b.id); + const kinds = new Map(segments.map((b) => [b.id, b.kind])); const persistentIds = new Set( segmentIds.filter((id) => this.prevMeasures[id]), @@ -263,13 +334,33 @@ export class TextMorph { ? computeDelta(this.prevMeasures, firstFrameMeasures, deltaKey) : { dx: 0, dy: 0 }; - animateEnterOrPersist(child, { - deltaX, - deltaY, - isNew, - duration: this.options.duration!, - ease: this.options.ease!, - }); + const kind = kinds.get(key); + + if (kind && isNew) { + animateNumberEnter(child, { + deltaX, + deltaY, + slideDistance, + kind, + duration: this.options.duration!, + ease: this.options.ease!, + }); + } else if (kind) { + animateNumberPersist(child, { + deltaX, + deltaY, + duration: this.options.duration!, + ease: this.options.ease!, + }); + } else { + animateEnterOrPersist(child, { + deltaX, + deltaY, + isNew, + duration: this.options.duration!, + ease: this.options.ease!, + }); + } }); } } diff --git a/packages/torph/src/lib/text-morph/types.ts b/packages/torph/src/lib/text-morph/types.ts index 14cf7b1..46833f8 100644 --- a/packages/torph/src/lib/text-morph/types.ts +++ b/packages/torph/src/lib/text-morph/types.ts @@ -3,5 +3,12 @@ import type { BaseMorphOptions } from "../utils/types"; export interface TextMorphOptions extends BaseMorphOptions { debug?: boolean; scale?: boolean; + /** + * Morph numeric words by place value, sliding digits along the block axis. + * Off falls back to the character-level text morph. + */ + numbers?: boolean; + /** Fraction digits for a numeric value. Ignored for strings. */ + decimals?: number; onAnimationCancel?: () => void; } diff --git a/packages/torph/src/lib/number-morph/__tests__/cases.test.ts b/packages/torph/src/lib/text-morph/utils/__tests__/number-cases.test.ts similarity index 92% rename from packages/torph/src/lib/number-morph/__tests__/cases.test.ts rename to packages/torph/src/lib/text-morph/utils/__tests__/number-cases.test.ts index 7fe5602..03a4439 100644 --- a/packages/torph/src/lib/number-morph/__tests__/cases.test.ts +++ b/packages/torph/src/lib/text-morph/utils/__tests__/number-cases.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from "vitest"; import { NUMBER_CASES } from "@torph/test-cases"; -import { segmentNumber } from "../segment"; +import { segmentNumber } from "../number"; // Cases live in `packages/test-cases` — adding one there adds it here and to // the playground. diff --git a/packages/torph/src/lib/number-morph/__tests__/corpus-integrity.test.ts b/packages/torph/src/lib/text-morph/utils/__tests__/number-corpus-integrity.test.ts similarity index 98% rename from packages/torph/src/lib/number-morph/__tests__/corpus-integrity.test.ts rename to packages/torph/src/lib/text-morph/utils/__tests__/number-corpus-integrity.test.ts index 17f4954..4f32fb4 100644 --- a/packages/torph/src/lib/number-morph/__tests__/corpus-integrity.test.ts +++ b/packages/torph/src/lib/text-morph/utils/__tests__/number-corpus-integrity.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect } from "vitest"; import { NUMBER_CASES } from "@torph/test-cases"; import type { NumberSegment, NumberTorphApi } from "@torph/test-cases"; -import { segmentNumber } from "../segment"; +import { segmentNumber } from "../number"; // Guards the corpus, not the library: a `verify` can pass without asserting // anything and still look green. Each case is re-run against deliberately diff --git a/packages/torph/src/lib/number-morph/__tests__/segment.test.ts b/packages/torph/src/lib/text-morph/utils/__tests__/number-segment.test.ts similarity index 98% rename from packages/torph/src/lib/number-morph/__tests__/segment.test.ts rename to packages/torph/src/lib/text-morph/utils/__tests__/number-segment.test.ts index 5263894..21db362 100644 --- a/packages/torph/src/lib/number-morph/__tests__/segment.test.ts +++ b/packages/torph/src/lib/text-morph/utils/__tests__/number-segment.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import { decimalSeparator, segmentNumber } from "../segment"; +import { decimalSeparator, segmentNumber } from "../number"; /** * Where each character of `to` came from in `from`, by position — `null` for a diff --git a/packages/torph/src/lib/text-morph/utils/diff.ts b/packages/torph/src/lib/text-morph/utils/diff.ts index 039cba5..11af928 100644 --- a/packages/torph/src/lib/text-morph/utils/diff.ts +++ b/packages/torph/src/lib/text-morph/utils/diff.ts @@ -1,43 +1,85 @@ import type { Segment } from "./segment"; -import { createIdAllocator, segmentText } from "./segment"; +import { createIdAllocator, groupIntoWords, segmentText } from "./segment"; +import type { NumberSegment } from "./number"; +import { + classifyKind, + decimalSeparator, + hasDigit, + isNumericWord, + numbersAllowed, + numericSkeleton, + segmentNumber, +} from "./number"; export type DiffResult = { segments: Segment[]; splits: Map; }; -type WordGroup = { - word: string; - segments: Segment[]; +export type DiffOptions = { + /** Numeric words morph by place value. Off falls back to character LCS. */ + numbers?: boolean; + /** Caret position, honoured only when the value holds a single number. */ + cursorIndex?: number; }; -function groupIntoWords(segments: Segment[]): WordGroup[] { - const groups: WordGroup[] = []; - let current: Segment[] = []; +/** + * Stands in for every numeric word while the word-level LCS runs. + * + * Two numbers have almost no characters in common — "$1,234" against "$5,678" + * scores below the morph-pairing threshold — so left to itself the diff retires + * one number and introduces the other as a stranger. Collapsing them to a + * single token makes the LCS pair *a number with a number* positionally, which + * is what carries "the second figure in the sentence" from one value to the + * next. Prefixed with a NULL so no real word can spell it. + */ +const NUMBER_TOKEN = "\u0000#"; - for (const seg of segments) { - if (seg.string === "\u00A0" || seg.string === "\n") { - if (current.length > 0) { - groups.push({ - word: current.map((s) => s.string).join(""), - segments: [...current], - }); - current = []; - } - } else { - current.push(seg); - } - } - if (current.length > 0) { - groups.push({ - word: current.map((s) => s.string).join(""), - segments: [...current], - }); +/** + * The per-character segments of an old word, cutting it up first if it is still + * a single span. The cut is registered in `splits` so `splitWordSpans` performs + * the matching surgery on the DOM before anything is measured. + */ +function splitIfWhole( + oldGroup: { word: string; segments: Segment[] }, + splits: Map, +): Segment[] { + // A one-character word is already as split as it gets. Cutting it anyway + // would mint a new ID for a character that never moved, and a single-digit + // counter would re-enter its digit on every tick. + if (oldGroup.segments.length !== 1 || oldGroup.word.length <= 1) { + return oldGroup.segments; } - return groups; + const wordSeg = oldGroup.segments[0]!; + const charSegs = oldGroup.word.split("").map((char, i) => ({ + id: `${wordSeg.id}:${i}`, + string: char, + })); + splits.set(wordSeg.id, charSegs); + + return charSegs; +} + +/** Fills in kinds an older, non-numeric segmentation of the same word lacked. */ +function asNumberSegments(segments: Segment[]): NumberSegment[] { + return segments.map((seg) => ({ + ...seg, + kind: seg.kind ?? classifyKind(seg.string), + })); } +/** + * How a new word gets its segments. Resolved once, then read by both the ID + * reservation pre-pass and the build loop — they have to agree on which words + * are about to be split, and drifting apart silently duplicates an ID. + */ +type WordPlan = + | { mode: "fresh" } + | { mode: "reuse"; oi: number } + | { mode: "morph"; oi: number } + | { mode: "number"; oi: number }; + /** * Longest common subsequence, reported as paired indices into `a` and `b`. * @@ -90,6 +132,25 @@ function charSimilarity(a: string, b: string): number { const MIN_SIMILARITY = 0.4; +/** + * How strongly an old word claims a new one when neither the LCS nor an exact + * match placed it. + * + * Shared characters are the wrong measure for a quantity: "$420" and "$" have + * one character in common out of four, and they are unmistakably the same + * figure being emptied out. So when digits are in play on either side, matching + * skeletons settle it outright and the character count never gets a vote. + */ +function pairAffinity(a: string, b: string): number { + if ( + (hasDigit(a) || hasDigit(b)) && + numericSkeleton(a) === numericSkeleton(b) + ) { + return 1; + } + return charSimilarity(a, b); +} + // The whole diff runs synchronously before the first frame, so past these // budgets it degrades the animation rather than blocking on a long value. const MAX_MORPH_PAIRINGS = 2_500; @@ -99,13 +160,34 @@ export function diffSegments( oldSegments: Segment[], newText: string, locale: Intl.LocalesArgument, + options: DiffOptions = {}, ): DiffResult { const newHasSpaces = newText.includes(" "); const newHasNewlines = newText.includes("\n"); const oldWords = groupIntoWords(oldSegments); - if (oldWords.length <= 1 && !newHasSpaces && !newHasNewlines) { - return { segments: segmentText(newText, locale), splits: new Map() }; + const numbersOn = numbersAllowed(newText, options.numbers !== false); + const isNum = (word: string) => numbersOn && isNumericWord(word); + const token = (word: string) => (isNum(word) ? NUMBER_TOKEN : word); + + // Re-segmenting from scratch keeps text identity for free, because a text ID + // is derived from the text: "cart" and "card" agree on "c", "a" and "r" + // without the diff being consulted. Numeric IDs are minted, so they survive + // nothing — any value with a digit on either side has to take the long way + // round or the whole figure re-enters. + const digitsInvolved = + numbersOn && (hasDigit(newText) || oldWords.some((g) => hasDigit(g.word))); + + if ( + oldWords.length <= 1 && + !newHasSpaces && + !newHasNewlines && + !digitsInvolved + ) { + return { + segments: segmentText(newText, locale, numbersOn), + splits: new Map(), + }; } const newWordStrings: string[] = []; @@ -126,10 +208,16 @@ export function diffSegments( const oldWordStrings = oldWords.map((g) => g.word); if (oldWordStrings.length * newWordStrings.length > MAX_LCS_CELLS) { - return { segments: segmentText(newText, locale), splits: new Map() }; + return { + segments: segmentText(newText, locale, numbersOn), + splits: new Map(), + }; } - const [oldLcsIdx, newLcsIdx] = lcsIndices(oldWordStrings, newWordStrings); + const [oldLcsIdx, newLcsIdx] = lcsIndices( + oldWordStrings.map(token), + newWordStrings.map(token), + ); const oldMatchedSet = new Set(oldLcsIdx); const newMatchedSet = new Set(newLcsIdx); @@ -150,7 +238,7 @@ export function diffSegments( for (const ni of newUnmatched) { for (const oi of oldUnmatched) { if (exactUsed.has(oi)) continue; - if (newWordStrings[ni] === oldWordStrings[oi]) { + if (token(newWordStrings[ni]!) === token(oldWordStrings[oi]!)) { newToOldWord.set(ni, oi); exactUsed.add(oi); break; @@ -172,7 +260,12 @@ export function diffSegments( for (const oi of oldUnmatched) { if (usedOld.has(oi)) continue; - const sim = charSimilarity(oldWordStrings[oi]!, newWordStrings[ni]!); + // Numeric and non-numeric words pair freely here. Whether a token is a + // quantity is not the same question as whether it is *this* token's + // predecessor: deleting the last digit of "$4" leaves "$", which has no + // digits left to be a number by and is still the very same "$". + // `MIN_SIMILARITY` is what keeps a number from claiming a real word. + const sim = pairAffinity(oldWordStrings[oi]!, newWordStrings[ni]!); if (sim > bestSim) { bestSim = sim; bestOi = oi; @@ -186,17 +279,39 @@ export function diffSegments( } } + // Keyed on what the word is *becoming*, not on what it was: a value on its + // way to being a number should align by place even if its predecessor had no + // digits yet, and one on its way out of being a number should not. + // + // A word paired by the LCS is identical to its partner unless the token stood + // in for it, which only ever happens between two numbers — so anything left + // over for `reuse` really is the same word. + const plans: WordPlan[] = newWordStrings.map((newWord, ni): WordPlan => { + const lcsOi = newToOldWord.get(ni); + const oi = lcsOi ?? morphPairs.get(ni); + if (oi === undefined) return { mode: "fresh" }; + if (isNum(newWord)) return { mode: "number", oi }; + return lcsOi !== undefined ? { mode: "reuse", oi } : { mode: "morph", oi }; + }); + + // Meaningless once a sentence holds several figures, so it is spent only on + // the value that is unambiguously one number. + const cursorIndex = + plans.filter((plan) => plan.mode === "number").length === 1 + ? options.cursorIndex + : undefined; + const decimalChar = decimalSeparator(locale); + const alloc = createIdAllocator(); // Inherited IDs are reserved up front: the allocator only avoids collisions // with IDs it already knows about, so one inherited later in the build loop // would otherwise be handed to an earlier new segment. - for (let ni = 0; ni < newWordStrings.length; ni++) { - const oi = newToOldWord.get(ni) ?? morphPairs.get(ni); - if (oi === undefined) continue; - const oldGroup = oldWords[oi]!; + for (const plan of plans) { + if (plan.mode === "fresh") continue; + const oldGroup = oldWords[plan.oi]!; - if (!newToOldWord.has(ni) && oldGroup.segments.length === 1) { + if (plan.mode !== "reuse" && oldGroup.segments.length === 1) { // About to be split into per-character spans const wordSeg = oldGroup.segments[0]!; for (let i = 0; i < oldGroup.word.length; i++) { @@ -233,27 +348,26 @@ export function diffSegments( for (let ni = 0; ni < newWordStrings.length; ni++) { pushSeparators(newSeparators[ni] ?? (ni > 0 ? [" "] : [])); - if (newToOldWord.has(ni)) { - const oi = newToOldWord.get(ni)!; - const oldGroup = oldWords[oi]!; - for (const seg of oldGroup.segments) segments.push(seg); - } else if (morphPairs.has(ni)) { - const oi = morphPairs.get(ni)!; - const oldGroup = oldWords[oi]!; + const plan = plans[ni]!; + const newWord = newWordStrings[ni]!; + + if (plan.mode === "reuse") { + for (const seg of oldWords[plan.oi]!.segments) segments.push(seg); + } else if (plan.mode === "number") { + const oldGroup = oldWords[plan.oi]!; + + segments.push( + ...segmentNumber( + newWord, + asNumberSegments(splitIfWhole(oldGroup, splits)), + cursorIndex === undefined ? undefined : cursorIndex - charOffset, + decimalChar, + ), + ); + } else if (plan.mode === "morph") { + const oldGroup = oldWords[plan.oi]!; const oldWord = oldGroup.word; - const newWord = newWordStrings[ni]!; - - let oldCharSegs: Segment[]; - if (oldGroup.segments.length === 1) { - const wordSeg = oldGroup.segments[0]!; - oldCharSegs = oldWord.split("").map((c, i) => ({ - id: `${wordSeg.id}:${i}`, - string: c, - })); - splits.set(wordSeg.id, oldCharSegs); - } else { - oldCharSegs = oldGroup.segments; - } + const oldCharSegs = splitIfWhole(oldGroup, splits); const oldChars = oldWord.split(""); const newChars = newWord.split(""); @@ -278,14 +392,13 @@ export function diffSegments( }); } } + } else if (isNum(newWord)) { + segments.push(...segmentNumber(newWord)); } else { - segments.push({ - id: alloc.take(newWordStrings[ni]!), - string: newWordStrings[ni]!, - }); + segments.push({ id: alloc.take(newWord), string: newWord }); } - charOffset += newWordStrings[ni]!.length; + charOffset += newWord.length; } pushSeparators(trailingSeparators); diff --git a/packages/torph/src/lib/number-morph/animate.ts b/packages/torph/src/lib/text-morph/utils/number-animate.ts similarity index 97% rename from packages/torph/src/lib/number-morph/animate.ts rename to packages/torph/src/lib/text-morph/utils/number-animate.ts index 5a66e88..789c803 100644 --- a/packages/torph/src/lib/number-morph/animate.ts +++ b/packages/torph/src/lib/text-morph/utils/number-animate.ts @@ -1,4 +1,4 @@ -import { parseTranslate, cancelAnimations } from "../utils/animate"; +import { parseTranslate, cancelAnimations } from "../../utils/animate"; /** * Fades are a share of the morph rather than a fixed length, and the outgoing diff --git a/packages/torph/src/lib/number-morph/segment.ts b/packages/torph/src/lib/text-morph/utils/number.ts similarity index 60% rename from packages/torph/src/lib/number-morph/segment.ts rename to packages/torph/src/lib/text-morph/utils/number.ts index 2a28b27..7146f6b 100644 --- a/packages/torph/src/lib/number-morph/segment.ts +++ b/packages/torph/src/lib/text-morph/utils/number.ts @@ -1,24 +1,113 @@ -export type NumberSegment = { - id: string; - string: string; - kind: "digit" | "symbol"; -}; +import type { Segment, SegmentKind } from "../../utils/types"; +export type NumberSegment = Segment & { kind: SegmentKind }; + +/** + * Numeric IDs share one namespace with the text segments around them, and a + * collision between the two would hand one DOM node to two characters. Text IDs + * are derived from the text itself, so a prefix that cannot occur in content + * keeps the two sets disjoint by construction, and a counter that only ever + * climbs keeps every minted ID unique for the life of the page — including + * against an ID a number is still carrying from several morphs ago. + */ +const MINTED_PREFIX = "\u0000n"; let nextNewId = 0; -function isDigit(char: string): boolean { +function mintId(): string { + return `${MINTED_PREFIX}${nextNewId++}`; +} + +export function isDigit(char: string): boolean { return char >= "0" && char <= "9"; } -function classifyKind(char: string): NumberSegment["kind"] { +export function hasDigit(value: string): boolean { + for (const char of value) { + if (isDigit(char)) return true; + } + return false; +} + +/** + * What is left of a token once the volatile part of a quantity is removed. + * + * Digits and the separators between them are exactly what a number is expected + * to churn through, so they say nothing about whether two tokens are the same + * thing. What is left — "$", "%", "()" — is the part that holds still, and two + * tokens sharing it are the same figure at different magnitudes. + */ +export function numericSkeleton(word: string): string { + let out = ""; + for (const char of word) { + if (!isDigit(char) && !CORE_SEPARATORS.includes(char)) out += char; + } + return out; +} + +/** + * Whether numeric morphing applies to a value at all. + * + * A multi-line root is taller than one line, so the block-axis mask that hides a + * sliding digit has nothing to hide it against — the digit would simply travel + * over the line below. The rule lives here because `segmentText`, `diffSegments` + * and the engine all have to reach the same verdict; two of them disagreeing + * puts a kind on a segment the root cannot clip. + */ +export function numbersAllowed(value: string, enabled = true): boolean { + return enabled && !value.includes("\n"); +} + +/** Separators that can appear *between* digits without ending the number. */ +const CORE_SEPARATORS = ".,'\u00A0\u202F\u2009\u2007"; +const PREFIX_CHARS = "+-\u2212(#"; +const SUFFIX_CHARS = "%.,!?:;)\"'\u201D\u2019"; +const CURRENCY = /\p{Sc}/u; + +function isAffix(char: string, set: string): boolean { + return set.includes(char) || CURRENCY.test(char); +} + +/** + * Whether a whitespace-delimited token is a quantity, and so should morph by + * place value rather than by character. + * + * The test is deliberately strict — the whole token has to be a number, give or + * take a currency symbol on the front and punctuation on the back. Merely + * *containing* a digit is not enough: "COVID-19", "GPT-4" and "2024-01-01" all + * do, and none of them has a units column. Reading them as numbers would slide + * their letters around on a rule no one asked for, and this is on by default. + * + * Trailing sentence punctuation is stripped first, so the figure in "it cost + * $1,234." is still a figure. + */ +export function isNumericWord(word: string): boolean { + let start = 0; + let end = word.length; + + while (start < end && isAffix(word[start]!, PREFIX_CHARS)) start++; + while (end > start && isAffix(word[end - 1]!, SUFFIX_CHARS)) end--; + + if (start >= end) return false; + if (!isDigit(word[start]!) || !isDigit(word[end - 1]!)) return false; + + for (let i = start; i < end; i++) { + const char = word[i]!; + if (!isDigit(char) && !CORE_SEPARATORS.includes(char)) return false; + } + + return true; +} + +export function classifyKind(char: string): SegmentKind { return isDigit(char) ? "digit" : "symbol"; } const separators = new Map(); /** The locale's decimal separator — the pivot every alignment is measured from. */ -export function decimalSeparator(locale: string): string { - const cached = separators.get(locale); +export function decimalSeparator(locale: Intl.LocalesArgument): string { + const key = String(locale); + const cached = separators.get(key); if (cached) return cached; let separator = "."; @@ -31,7 +120,7 @@ export function decimalSeparator(locale: string): string { // Invalid locale tag. `toLocaleString` surfaces it on the first number. } - separators.set(locale, separator); + separators.set(key, separator); return separator; } @@ -86,9 +175,9 @@ export function segmentNumber( kind, }); } else { - let id = `${char}_n${nextNewId++}`; + let id = mintId(); while (usedIds.has(id)) { - id = `${char}_n${nextNewId++}`; + id = mintId(); } usedIds.add(id); result.push({ id, string: displayChar, kind }); @@ -98,29 +187,13 @@ export function segmentNumber( return result; } -/** Occurrence-based segmentation for initial render. */ +/** Fresh segmentation for a number with nothing to carry over from. */ function simpleSegment(chars: string[]): NumberSegment[] { - const counts = new Map(); - - return chars.map((char) => { - const kind = classifyKind(char); - const count = counts.get(char) ?? 0; - counts.set(char, count + 1); - - if (char === " ") { - return { - id: count > 0 ? `space_${count}` : "space", - string: "\u00A0", - kind, - }; - } - - return { - id: count > 0 ? `${char}_${count}` : char, - string: char, - kind, - }; - }); + return chars.map((char) => ({ + id: mintId(), + string: char === " " ? "\u00A0" : char, + kind: classifyKind(char), + })); } /** diff --git a/packages/torph/src/lib/text-morph/utils/segment.ts b/packages/torph/src/lib/text-morph/utils/segment.ts index 4c82a33..023965e 100644 --- a/packages/torph/src/lib/text-morph/utils/segment.ts +++ b/packages/torph/src/lib/text-morph/utils/segment.ts @@ -1,5 +1,6 @@ export type { Segment } from "../../utils/types"; import type { Segment } from "../../utils/types"; +import { isNumericWord, numbersAllowed, segmentNumber } from "./number"; // IDs are the identity used for FLIP tracking and DOM reconciliation, so a // collision makes two segments fight over one element and one of them silently @@ -30,13 +31,83 @@ export function createIdAllocator() { export type IdAllocator = ReturnType; +/** + * Splits segments into whitespace-delimited words, the unit the word-level diff + * aligns on. A number is whatever one of those words turns out to be, so this is + * also what decides where a number starts and ends. + */ +export function groupIntoWords(segments: Segment[]): { + word: string; + segments: Segment[]; +}[] { + const groups: { word: string; segments: Segment[] }[] = []; + let current: Segment[] = []; + + const flush = () => { + if (current.length === 0) return; + groups.push({ + word: current.map((s) => s.string).join(""), + segments: current, + }); + current = []; + }; + + for (const seg of segments) { + if (seg.string === "\u00A0" || seg.string === "\n") flush(); + else current.push(seg); + } + flush(); + + return groups; +} + +/** + * Re-cuts every numeric word into per-character segments carrying a kind. + * + * Run as a pass over the finished segmentation rather than inside it: word + * segmentation is `Intl.Segmenter`'s job and it splits a token like "$1,234" + * on its own terms, which is the wrong shape for place matching. Regrouping + * afterwards on whitespace is what keeps this pass and the diff agreeing on + * where a number begins. + * + * The IDs abandoned here stay reserved in the allocator. That costs nothing — + * they are only ever checked for collisions — and the alternative is deciding + * what is a number before knowing where the words are. + */ +function expandNumbers(segments: Segment[]): Segment[] { + const out: Segment[] = []; + let run: Segment[] = []; + + const flush = () => { + if (run.length === 0) return; + const word = run.map((s) => s.string).join(""); + if (isNumericWord(word)) out.push(...segmentNumber(word)); + else out.push(...run); + run = []; + }; + + for (const seg of segments) { + if (seg.string === "\u00A0" || seg.string === "\n") { + flush(); + out.push(seg); + } else { + run.push(seg); + } + } + flush(); + + return out; +} + export function segmentText( value: string, locale: Intl.LocalesArgument, + numbers = true, ): Segment[] { const hasNewlines = value.includes("\n"); const byWord = value.includes(" ") || hasNewlines; const alloc = createIdAllocator(); + const withNumbers = numbersAllowed(value, numbers); if (hasNewlines) { // `offset` is the character index into the full value, so IDs derived from @@ -59,10 +130,11 @@ export function segmentText( offset += line.length; }); - return allSegments; + return withNumbers ? expandNumbers(allSegments) : allSegments; } - return segmentLine(value, locale, byWord, 0, alloc); + const segments = segmentLine(value, locale, byWord, 0, alloc); + return withNumbers ? expandNumbers(segments) : segments; } function segmentLine( diff --git a/packages/torph/src/lib/utils/constants.ts b/packages/torph/src/lib/utils/constants.ts index dfc5cc9..f5aecb8 100644 --- a/packages/torph/src/lib/utils/constants.ts +++ b/packages/torph/src/lib/utils/constants.ts @@ -1,6 +1,7 @@ export const ATTR_ROOT = "torph-root"; export const ATTR_ITEM = "torph-item"; export const ATTR_ID = "torph-id"; +export const ATTR_KIND = "torph-kind"; export const ATTR_EXITING = "torph-exiting"; export const ATTR_DEBUG = "torph-debug"; export const EMPTY_ID = "empty"; diff --git a/packages/torph/src/lib/utils/dom.ts b/packages/torph/src/lib/utils/dom.ts index 5d319cc..8f7f18f 100644 --- a/packages/torph/src/lib/utils/dom.ts +++ b/packages/torph/src/lib/utils/dom.ts @@ -1,5 +1,5 @@ import type { Segment } from "./types"; -import { ATTR_EXITING, ATTR_ID, ATTR_ITEM } from "./constants"; +import { ATTR_EXITING, ATTR_ID, ATTR_ITEM, ATTR_KIND } from "./constants"; export function detachFromFlow( container: HTMLElement, @@ -73,6 +73,7 @@ export function splitWordSpans( const span = document.createElement("span"); span.setAttribute(ATTR_ITEM, ""); span.setAttribute(ATTR_ID, seg.id); + applyKind(span, seg); span.textContent = seg.string; child.before(span); } @@ -80,6 +81,13 @@ export function splitWordSpans( } } +// An exit outlives the segment that described it — the element is all that is +// left by the time it animates — so the kind has to live on the element. +function applyKind(element: HTMLElement, segment: Segment) { + if (segment.kind) element.setAttribute(ATTR_KIND, segment.kind); + else element.removeAttribute(ATTR_KIND); +} + export function reconcileChildren( element: HTMLElement, oldChildren: HTMLElement[], @@ -122,11 +130,13 @@ export function reconcileChildren( if (existing && existing.tagName !== "BR") { existing.textContent = segment.string; + applyKind(existing, segment); element.appendChild(existing); } else { const span = document.createElement("span"); span.setAttribute(ATTR_ITEM, ""); span.setAttribute(ATTR_ID, segment.id); + applyKind(span, segment); span.textContent = segment.string; element.appendChild(span); } diff --git a/packages/torph/src/lib/utils/styles.ts b/packages/torph/src/lib/utils/styles.ts index 54be662..cac2359 100644 --- a/packages/torph/src/lib/utils/styles.ts +++ b/packages/torph/src/lib/utils/styles.ts @@ -46,3 +46,90 @@ export function removeStyles() { styleEl.remove(); styleEl = null; } + +const MASK_PROPERTIES = [ + "mask-image", + "mask-repeat", + "mask-clip", + "mask-size", + "mask-position", +] as const; + +/** + * Digits slide vertically past the line box on enter and exit, so the block + * axis is masked. The inline axis must stay visible: the container spends the + * whole duration animating to its new width, and clipping it would mask every + * character sitting beyond the old width until the size transition catches up. + * + * `clip` is what makes that split legal — `overflow-x: visible` next to + * `overflow-y: hidden` computes to `auto`, which would scroll instead. + */ +function clipBlockAxis(element: HTMLElement) { + if (CSS.supports("overflow", "clip")) { + element.style.overflowX = "visible"; + element.style.overflowY = "clip"; + } else { + element.style.overflow = "hidden"; + } +} + +/** + * Softens the block-axis clip into a gradient, so characters dissolve across + * the edge of the line box instead of meeting a hard line. Positional rather + * than timed: how faint a character is depends on where it has slid to, which + * keeps it in step with its own movement at any duration. + * + * The band is `--torph-fade` on the root — set it to `0` for a hard edge. + * + * The tile is grown past the line box by `--torph-overshoot` so the fade + * begins *outside* it. Digits never needed that, but this root now holds + * arbitrary text: at a tight `line-height` a descender or a diacritic overflows + * the line box, and a band flush with the edge would ghost the tail of every + * "g" in the sentence. + * + * `no-clip` is load-bearing. A mask layer is otherwise clipped to the border + * box, which would hide every character sitting beyond the container's + * animating width — exactly what the visible inline axis exists to show. + * `repeat-x` then carries the same profile across that overflow, while the + * block axis stays a single tile so anything above or below the box is masked + * out. Without `no-clip` the mask would cost more than it gives, so the hard + * clip stands in. + */ +export function applyBlockFade(element: HTMLElement) { + clipBlockAxis(element); + + if ( + !CSS.supports("mask-clip", "no-clip") && + !CSS.supports("-webkit-mask-clip", "no-clip") + ) { + return; + } + + const fade = "var(--torph-fade, 0.15em)"; + const overshoot = "var(--torph-overshoot, 0.25em)"; + + setMaskProperty( + element, + "mask-image", + `linear-gradient(to bottom, transparent, #000 ${fade}, #000 calc(100% - ${fade}), transparent)`, + ); + setMaskProperty(element, "mask-repeat", "repeat-x"); + setMaskProperty(element, "mask-clip", "no-clip"); + setMaskProperty(element, "mask-size", `100% calc(100% + 2 * ${overshoot})`); + setMaskProperty(element, "mask-position", "center"); +} + +export function clearBlockFade(element: HTMLElement) { + element.style.overflow = ""; + element.style.overflowX = ""; + element.style.overflowY = ""; + MASK_PROPERTIES.forEach((property) => { + element.style.removeProperty(property); + element.style.removeProperty(`-webkit-${property}`); + }); +} + +function setMaskProperty(element: HTMLElement, property: string, value: string) { + element.style.setProperty(property, value); + element.style.setProperty(`-webkit-${property}`, value); +} diff --git a/packages/torph/src/lib/utils/types.ts b/packages/torph/src/lib/utils/types.ts index 59a619d..ab6b2ac 100644 --- a/packages/torph/src/lib/utils/types.ts +++ b/packages/torph/src/lib/utils/types.ts @@ -1,8 +1,18 @@ import type { SpringParams } from "./spring"; +/** + * A segment that belongs to a number. Digits and the symbols around them slide + * along the block axis instead of fading in place, so the animation has to be + * able to tell them apart from ordinary text — and from each other, since they + * slide in opposite directions. + */ +export type SegmentKind = "digit" | "symbol"; + export type Segment = { id: string; string: string; + /** Absent for ordinary text. */ + kind?: SegmentKind; }; export interface BaseMorphOptions { diff --git a/packages/torph/src/react/NumberMorph.tsx b/packages/torph/src/react/NumberMorph.tsx deleted file mode 100644 index ff5cf2a..0000000 --- a/packages/torph/src/react/NumberMorph.tsx +++ /dev/null @@ -1,92 +0,0 @@ -"use client"; - -import React from "react"; -import { NumberMorphController } from "../lib/number-morph/controller"; -import type { NumberMorphOptions } from "../lib/number-morph/types"; - -export type NumberMorphProps = Omit & { - children: number | string; - cursorIndex?: number; - className?: string; - style?: React.CSSProperties; - as?: React.ElementType; -}; - -function childrenToValue(node: React.ReactNode): number | string { - if (typeof node === "string") return node; - if (typeof node === "number") return node; - if (Array.isArray(node)) return node.map(childrenToValue).join(""); - return ""; -} - -export const NumberMorph = ({ - children, - cursorIndex, - className, - style, - as: Component = "span", - ...props -}: NumberMorphProps) => { - const { ref, update } = useNumberMorph(props); - const value = childrenToValue(children); - const cursorRef = React.useRef(cursorIndex); - cursorRef.current = cursorIndex; - const initialHTML = React.useRef({ - __html: typeof value === "number" ? String(value) : value, - }); - - React.useEffect(() => { - update(value, cursorRef.current); - }, [value, update]); - - return ( - - ); -}; - -export function useNumberMorph(props: Omit) { - const ref = React.useRef(null); - const controllerRef = React.useRef(new NumberMorphController()); - - const configKey = NumberMorphController.serializeConfig(props); - - // Callbacks are deliberately absent from the config key — changing one should - // not tear the morph down. That leaves them captured at attach time, so they - // are called through a ref instead: a handler closing over component state is - // the normal case, and a frozen one would silently keep reading the state it - // saw on mount. - const handlers = React.useRef(props); - handlers.current = props; - - React.useEffect(() => { - const controller = controllerRef.current; - if (ref.current) { - controller.attach(ref.current, { - ...props, - onAnimationStart: () => handlers.current.onAnimationStart?.(), - onAnimationComplete: () => handlers.current.onAnimationComplete?.(), - }); - } - - return () => { - controller.destroy(); - }; - // Keyed on the serialized config: re-attaching on every `props` identity - // would tear the controller down on each render. - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [configKey]); - - const update = React.useCallback( - (value: number | string, cursorIndex?: number) => { - controllerRef.current.update(value, cursorIndex); - }, - [], - ); - - return { ref, update }; -} diff --git a/packages/torph/src/react/TextMorph.tsx b/packages/torph/src/react/TextMorph.tsx index 4790c65..6467331 100644 --- a/packages/torph/src/react/TextMorph.tsx +++ b/packages/torph/src/react/TextMorph.tsx @@ -7,11 +7,25 @@ import type { TextMorphOptions } from "../lib/text-morph/types"; export type TextMorphProps = Omit & { children: React.ReactNode; + /** + * Caret position, for a value that is a single number. Switches that step + * from place matching to caret matching — what an editable field wants. + */ + cursorIndex?: number; className?: string; style?: React.CSSProperties; as?: React.ElementType; }; +/** + * A lone number is handed over as a number so `locale` and `decimals` can + * format it. Anything else is already text by the time it gets here. + */ +function childrenToValue(node: React.ReactNode): string | number { + if (typeof node === "number") return node; + return childrenToString(node); +} + function childrenToString(node: React.ReactNode): string { if (typeof node === "string") return node; if (typeof node === "number") return String(node); @@ -38,20 +52,23 @@ function escapeHTML(value: string): string { export const TextMorph = ({ children, + cursorIndex, className, style, as = DEFAULT_AS, ...props }: TextMorphProps) => { const { ref, update } = useTextMorph(props); - const text = childrenToString(children); + const value = childrenToValue(children); + const cursorRef = React.useRef(cursorIndex); + cursorRef.current = cursorIndex; const initialHTML = React.useRef({ - __html: escapeHTML(text).replace(/\n/g, "
"), + __html: escapeHTML(String(value)).replace(/\n/g, "
"), }); React.useEffect(() => { - update(text); - }, [text, update]); + update(value, cursorRef.current); + }, [value, update]); const Component = as; @@ -97,9 +114,12 @@ export function useTextMorph(props: Omit) { // eslint-disable-next-line react-hooks/exhaustive-deps }, [configKey]); - const update = React.useCallback((text: string) => { - controllerRef.current.update(text); - }, []); + const update = React.useCallback( + (value: string | number, cursorIndex?: number) => { + controllerRef.current.update(value, cursorIndex); + }, + [], + ); return { ref, update }; } diff --git a/packages/torph/src/react/index.ts b/packages/torph/src/react/index.ts index a70c544..e0f964c 100644 --- a/packages/torph/src/react/index.ts +++ b/packages/torph/src/react/index.ts @@ -1,5 +1,2 @@ export { TextMorph, useTextMorph } from "./TextMorph"; export type { TextMorphProps } from "./TextMorph"; - -export { NumberMorph, useNumberMorph } from "./NumberMorph"; -export type { NumberMorphProps } from "./NumberMorph"; diff --git a/packages/torph/src/svelte/TextMorph.svelte b/packages/torph/src/svelte/TextMorph.svelte index 68d1dd6..ea4b4df 100644 --- a/packages/torph/src/svelte/TextMorph.svelte +++ b/packages/torph/src/svelte/TextMorph.svelte @@ -15,6 +15,7 @@ duration = DEFAULT_TEXT_MORPH_OPTIONS.duration, ease = DEFAULT_TEXT_MORPH_OPTIONS.ease, scale = DEFAULT_TEXT_MORPH_OPTIONS.scale, + numbers = DEFAULT_TEXT_MORPH_OPTIONS.numbers, debug = DEFAULT_TEXT_MORPH_OPTIONS.debug, disabled = DEFAULT_TEXT_MORPH_OPTIONS.disabled, respectReducedMotion = DEFAULT_TEXT_MORPH_OPTIONS.respectReducedMotion, @@ -29,7 +30,7 @@ const controller = new MorphController(); const options = $derived({ - locale, duration, ease, debug, scale, + locale, duration, ease, debug, scale, numbers, disabled, respectReducedMotion, onAnimationStart, onAnimationComplete, onAnimationCancel, }); diff --git a/packages/torph/src/vue/TextMorph.ts b/packages/torph/src/vue/TextMorph.ts index 1084178..6da752a 100644 --- a/packages/torph/src/vue/TextMorph.ts +++ b/packages/torph/src/vue/TextMorph.ts @@ -33,6 +33,7 @@ export default defineComponent({ default: DEFAULT_TEXT_MORPH_OPTIONS.ease, }, scale: { type: Boolean, default: DEFAULT_TEXT_MORPH_OPTIONS.scale }, + numbers: { type: Boolean, default: DEFAULT_TEXT_MORPH_OPTIONS.numbers }, debug: { type: Boolean, default: undefined }, disabled: { type: Boolean, default: DEFAULT_TEXT_MORPH_OPTIONS.disabled }, respectReducedMotion: { @@ -60,6 +61,7 @@ export default defineComponent({ ease: props.ease, debug: props.debug, scale: props.scale, + numbers: props.numbers, disabled: props.disabled, respectReducedMotion: props.respectReducedMotion, }), @@ -73,6 +75,7 @@ export default defineComponent({ ease: props.ease as TextMorphProps["ease"], debug: props.debug, scale: props.scale, + numbers: props.numbers, disabled: props.disabled, respectReducedMotion: props.respectReducedMotion, onAnimationStart: props.onAnimationStart as (() => void) | undefined, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f66d73d..7f934c7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -153,6 +153,9 @@ importers: globals: specifier: ^17.9.0 version: 17.9.0 + happy-dom: + specifier: ^20.12.0 + version: 20.12.0 prettier: specifier: ^3.0.3 version: 3.6.2 @@ -176,7 +179,7 @@ importers: version: 8.65.0(eslint@9.36.0)(typescript@5.9.3) vitest: specifier: ^4.0.18 - version: 4.0.18(@types/node@20.5.1)(jsdom@28.1.0)(sass@1.93.0)(yaml@2.8.1) + version: 4.0.18(@types/node@20.5.1)(happy-dom@20.12.0)(jsdom@28.1.0)(sass@1.93.0)(yaml@2.8.1) vue: specifier: ^3.3.0 version: 3.5.24(typescript@5.9.3) @@ -1478,6 +1481,12 @@ packages: '@types/trusted-types@2.0.7': resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} + '@types/whatwg-mimetype@3.0.2': + resolution: {integrity: sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==} + + '@types/ws@8.18.1': + resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} + '@typescript-eslint/eslint-plugin@8.56.0': resolution: {integrity: sha512-lRyPDLzNCuae71A3t9NEINBiTn7swyOhvUj3MyUOxb8x6g6vPEFoOU+ZRmGMusNC3X3YMhqMIX7i8ShqhT74Pw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -1964,6 +1973,10 @@ packages: engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true + buffer-image-size@0.6.4: + resolution: {integrity: sha512-nEh+kZOPY1w+gcCMobZ6ETUp9WfibndnosbpwB1iJk/8Gt5ZF2bhS6+B6bPYz424KtwsR6Rflc3tCz1/ghX2dQ==} + engines: {node: '>=4.0'} + bundle-require@5.1.0: resolution: {integrity: sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} @@ -2311,6 +2324,10 @@ packages: resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} engines: {node: '>=0.12'} + entities@7.0.1: + resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} + engines: {node: '>=0.12'} + error-ex@1.3.2: resolution: {integrity: sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==} @@ -2771,6 +2788,10 @@ packages: grapheme-splitter@1.0.4: resolution: {integrity: sha512-bzh50DW9kTPM00T8y4o8vQg89Di9oLJVLW/KaOGIXJWP/iqCN6WKYkbNOF04vFLJhwcpYUh9ydh/+5vpOqV4YQ==} + happy-dom@20.12.0: + resolution: {integrity: sha512-7uMYJu2SEwwL8vVcKp0C0lnt6d2LSGGe+T+oY79PiCJNNSgFpbxW8n5KuzpDQvrU4mt+fYiK1+Jy7Z2v39YR6g==} + engines: {node: '>=20.0.0'} + hard-rejection@2.1.0: resolution: {integrity: sha512-VIZB+ibDhx7ObhAe7OVtoEbuP4h/MuOTHJ+J8h/eBXotJYl0fBgR72xDFCKgIh22OJZIOVNxBMWuhAr10r8HdA==} engines: {node: '>=6'} @@ -4537,6 +4558,10 @@ packages: resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==} engines: {node: '>=20'} + whatwg-mimetype@3.0.0: + resolution: {integrity: sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==} + engines: {node: '>=12'} + whatwg-mimetype@5.0.0: resolution: {integrity: sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==} engines: {node: '>=20'} @@ -4608,6 +4633,18 @@ packages: resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} engines: {node: '>=12'} + ws@8.21.3: + resolution: {integrity: sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + xml-name-validator@5.0.0: resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} engines: {node: '>=18'} @@ -5867,6 +5904,12 @@ snapshots: '@types/trusted-types@2.0.7': {} + '@types/whatwg-mimetype@3.0.2': {} + + '@types/ws@8.18.1': + dependencies: + '@types/node': 20.5.1 + '@typescript-eslint/eslint-plugin@8.56.0(@typescript-eslint/parser@8.56.0(eslint@9.36.0)(typescript@5.9.3))(eslint@9.36.0)(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.2 @@ -6439,6 +6482,10 @@ snapshots: node-releases: 2.0.21 update-browserslist-db: 1.1.3(browserslist@4.26.2) + buffer-image-size@0.6.4: + dependencies: + '@types/node': 20.5.1 + bundle-require@5.1.0(esbuild@0.25.10): dependencies: esbuild: 0.25.10 @@ -6765,6 +6812,8 @@ snapshots: entities@6.0.1: optional: true + entities@7.0.1: {} + error-ex@1.3.2: dependencies: is-arrayish: 0.2.1 @@ -7489,6 +7538,19 @@ snapshots: grapheme-splitter@1.0.4: {} + happy-dom@20.12.0: + dependencies: + '@types/node': 20.5.1 + '@types/whatwg-mimetype': 3.0.2 + '@types/ws': 8.18.1 + buffer-image-size: 0.6.4 + entities: 7.0.1 + whatwg-mimetype: 3.0.0 + ws: 8.21.3 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + hard-rejection@2.1.0: {} has-bigints@1.0.2: {} @@ -9304,7 +9366,7 @@ snapshots: optionalDependencies: vite: 6.4.1(@types/node@20.5.1)(sass@1.93.0)(yaml@2.8.1) - vitest@4.0.18(@types/node@20.5.1)(jsdom@28.1.0)(sass@1.93.0)(yaml@2.8.1): + vitest@4.0.18(@types/node@20.5.1)(happy-dom@20.12.0)(jsdom@28.1.0)(sass@1.93.0)(yaml@2.8.1): dependencies: '@vitest/expect': 4.0.18 '@vitest/mocker': 4.0.18(vite@6.4.1(@types/node@20.5.1)(sass@1.93.0)(yaml@2.8.1)) @@ -9328,6 +9390,7 @@ snapshots: why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 20.5.1 + happy-dom: 20.12.0 jsdom: 28.1.0 transitivePeerDependencies: - jiti @@ -9373,6 +9436,8 @@ snapshots: webidl-conversions@8.0.1: optional: true + whatwg-mimetype@3.0.0: {} + whatwg-mimetype@5.0.0: optional: true @@ -9488,6 +9553,8 @@ snapshots: string-width: 5.1.2 strip-ansi: 7.1.0 + ws@8.21.3: {} + xml-name-validator@5.0.0: optional: true diff --git a/site/src/surfaces/homepage/examples/number.tsx b/site/src/surfaces/homepage/examples/number.tsx index 5a1940a..dedd954 100644 --- a/site/src/surfaces/homepage/examples/number.tsx +++ b/site/src/surfaces/homepage/examples/number.tsx @@ -1,7 +1,7 @@ import styles from "./styles.module.scss"; import React from "react"; -import { NumberMorph } from "torph/react"; +import { TextMorph } from "torph/react"; const sequence = [ // Type $20 @@ -54,7 +54,7 @@ export const ExampleNumber = () => { return (
- {step.value} + {step.value} { return (
- {formatValue(DATA[activeIndex].value)} + {formatValue(DATA[activeIndex].value)}
Monthly revenue · {DATA[activeIndex].month} diff --git a/site/src/surfaces/playground/config.ts b/site/src/surfaces/playground/config.ts index 0e4e9d8..c82de74 100644 --- a/site/src/surfaces/playground/config.ts +++ b/site/src/surfaces/playground/config.ts @@ -17,7 +17,7 @@ export type Speed = keyof typeof SPEEDS; export const ALIGNS = ["left", "center", "right"] as const; export type Align = (typeof ALIGNS)[number]; -// ── NumberMorph ── +// ── Numbers ── // en-IN earns its place: lakh/crore grouping (12,34,567) puts separators where // no other locale does, so it catches grouping assumed to be every three digits. diff --git a/site/src/surfaces/playground/index.tsx b/site/src/surfaces/playground/index.tsx index 8784017..8d07e96 100644 --- a/site/src/surfaces/playground/index.tsx +++ b/site/src/surfaces/playground/index.tsx @@ -25,7 +25,7 @@ const TICKER_DEMO = -4; const MODES = ["text", "numbers"] as const; type Mode = (typeof MODES)[number]; -// Scoped per mode: the chart and input demos drive NumberMorph, so they have +// Scoped per mode: the chart and input demos drive numeric morphs, so they have // nothing to show under text. The mode switch already says "numbers", so the // labels don't repeat it. const PANELS: Record = { diff --git a/site/src/surfaces/playground/input-demo/index.tsx b/site/src/surfaces/playground/input-demo/index.tsx index ef2fa4f..3947558 100644 --- a/site/src/surfaces/playground/input-demo/index.tsx +++ b/site/src/surfaces/playground/input-demo/index.tsx @@ -1,6 +1,6 @@ "use client"; -import { NumberMorph } from "torph/react"; +import { TextMorph } from "torph/react"; import styles from "./styles.module.scss"; import React from "react"; @@ -25,7 +25,7 @@ export const InputPlayground = () => { }} />
- {query} + {query}
diff --git a/site/src/surfaces/playground/input-demo/input/index.tsx b/site/src/surfaces/playground/input-demo/input/index.tsx index 3e624bb..60f7d94 100644 --- a/site/src/surfaces/playground/input-demo/input/index.tsx +++ b/site/src/surfaces/playground/input-demo/input/index.tsx @@ -7,7 +7,7 @@ import { AnimatePresence, motion } from "motion/react"; import styles from "./styles.module.scss"; import { stringToLocaleString } from "./utils"; import { useMouse } from "./useMouse"; -import { NumberMorph } from "torph/react"; +import { TextMorph } from "torph/react"; const MAX_FONT_SIZE = 80; const MIN_FONT_SIZE = 16; @@ -214,11 +214,11 @@ export const InputNumber = ({
- {formattedValue || "0"} - +
diff --git a/site/src/surfaces/playground/issue.ts b/site/src/surfaces/playground/issue.ts index 70bdcb8..ac89f8e 100644 --- a/site/src/surfaces/playground/issue.ts +++ b/site/src/surfaces/playground/issue.ts @@ -102,7 +102,7 @@ export async function copyText(value: string): Promise { } } -// ── NumberMorph ── +// ── Numbers ── function placeTable( from: string, @@ -155,7 +155,7 @@ export function buildNumberIssueReport({ const ease = EASINGS[easing]; return [ - `# torph issue — ${test.label} (NumberMorph)`, + `# torph issue — ${test.label} (numbers)`, "", notes ? `**What looks wrong:** ${notes}` diff --git a/site/src/surfaces/playground/number-detail.tsx b/site/src/surfaces/playground/number-detail.tsx index ffd524e..3be4066 100644 --- a/site/src/surfaces/playground/number-detail.tsx +++ b/site/src/surfaces/playground/number-detail.tsx @@ -1,5 +1,5 @@ import React from "react"; -import { NumberMorph } from "torph/react"; +import { TextMorph } from "torph/react"; import { decimalSeparator } from "torph"; import styles from "./styles.module.scss"; import { formatValue } from "./number-tests"; @@ -199,7 +199,7 @@ export function NumberDetail({ tabIndex={0} onKeyDown={(e) => e.key === "Enter" && advance()} > - {test.values[index]!} - +
diff --git a/site/src/surfaces/playground/number-sandbox.tsx b/site/src/surfaces/playground/number-sandbox.tsx index 0767634..6ba8e50 100644 --- a/site/src/surfaces/playground/number-sandbox.tsx +++ b/site/src/surfaces/playground/number-sandbox.tsx @@ -1,5 +1,5 @@ import React from "react"; -import { NumberMorph } from "torph/react"; +import { TextMorph } from "torph/react"; import { decimalSeparator } from "torph"; import styles from "./styles.module.scss"; import { SPEEDS, EASINGS, DECIMALS } from "./config"; @@ -155,7 +155,7 @@ export function NumberSandbox({ }} role="presentation" > - {value} - +
diff --git a/site/src/surfaces/playground/number-tests.ts b/site/src/surfaces/playground/number-tests.ts index 998c3f3..91e154c 100644 --- a/site/src/surfaces/playground/number-tests.ts +++ b/site/src/surfaces/playground/number-tests.ts @@ -17,7 +17,7 @@ export const NUMBER_TESTS: NumberBenchCase[] = NUMBER_CASES.map((testCase) => ({ export { ALL_NUMBER_TAGS }; -/** What `NumberMorph` will render for a value — numbers are formatted, strings are not. */ +/** What `TextMorph` will render for a numeric value — numbers are formatted, strings are not. */ export function formatValue( value: string | number, locale: string, diff --git a/site/src/surfaces/playground/ticker-demo.tsx b/site/src/surfaces/playground/ticker-demo.tsx index 2680083..0a86142 100644 --- a/site/src/surfaces/playground/ticker-demo.tsx +++ b/site/src/surfaces/playground/ticker-demo.tsx @@ -1,5 +1,5 @@ import React from "react"; -import { NumberMorph } from "torph/react"; +import { TextMorph } from "torph/react"; import styles from "./styles.module.scss"; import { SPEEDS, EASINGS, DECIMALS } from "./config"; import type { Speed, EasingKey, Align, Locale, DecimalsKey } from "./config"; @@ -106,14 +106,14 @@ export function TickerDemo({ }} role="presentation" > - {format(value, kind, locale)} - +
From 4ed4ecabbcf93be24d3dca52301600521f43be83 Mon Sep 17 00:00:00 2001 From: Lochie Axon Date: Wed, 2 Sep 2026 02:35:51 +1000 Subject: [PATCH 07/10] slots --- packages/test-cases/src/cases.ts | 91 +++++++- packages/test-cases/src/index.ts | 1 + packages/test-cases/src/verify.ts | 30 +++ .../lib/text-morph/__tests__/engine.test.ts | 206 +++++++++++++----- packages/torph/src/lib/text-morph/index.ts | 43 +--- .../torph/src/lib/text-morph/utils/diff.ts | 3 +- .../lib/text-morph/utils/number-animate.ts | 98 ++++----- .../torph/src/lib/text-morph/utils/number.ts | 13 -- .../torph/src/lib/text-morph/utils/segment.ts | 7 +- packages/torph/src/lib/utils/constants.ts | 1 + packages/torph/src/lib/utils/dom.ts | 63 +++++- packages/torph/src/lib/utils/styles.ts | 145 +++++------- 12 files changed, 440 insertions(+), 261 deletions(-) diff --git a/packages/test-cases/src/cases.ts b/packages/test-cases/src/cases.ts index 248349a..8f78680 100644 --- a/packages/test-cases/src/cases.ts +++ b/packages/test-cases/src/cases.ts @@ -6,6 +6,7 @@ import { verifyCycleStability, verifyGraphemeMorph, verifyKinds, + verifyKindsAfterMorph, verifyNoMorph, verifyTextPlaces, verifyWordAbsent, @@ -263,7 +264,7 @@ export const CASES: TestCase[] = [ { label: "Version strings stay text", description: - "A token has to be a quantity all the way through to morph as one. \"v1.2.3\" has no units column, so it morphs character by character like any other word.", + 'A token has to be a quantity all the way through to morph as one. "v1.2.3" has no units column, so it morphs character by character like any other word.', tags: ["number"], values: ["v1.2.3", "v1.3.0", "v2.0.0"], verify: (t) => verifyKinds(t, "v1.2.3", new Array(6).fill(undefined)), @@ -285,7 +286,7 @@ export const CASES: TestCase[] = [ { label: "Emptying a number to its affix", description: - "Backspacing the last digit out of \"$4\" leaves a token with no digits left to be a number by. The dollar sign is still the same dollar sign, so it holds rather than re-entering.", + 'Backspacing the last digit out of "$4" leaves a token with no digits left to be a number by. The dollar sign is still the same dollar sign, so it holds rather than re-entering.', tags: ["number", "exit"], values: ["$4", "$", "$4", "$420"], verify: (t) => @@ -297,7 +298,7 @@ export const CASES: TestCase[] = [ { label: "A number never claims a word", description: - "\"5\" and \"five\" are the same quantity and share no characters, so the digit leaves and the word arrives. Spelling is the only thing the diff can see.", + '"5" and "five" are the same quantity and share no characters, so the digit leaves and the word arrives. Spelling is the only thing the diff can see.', tags: ["number"], values: ["5 items", "five items"], verify: (t) => @@ -323,6 +324,90 @@ export const CASES: TestCase[] = [ [6, 6], ]), }, + { + label: "A number holds across a new line", + description: + "A second line arrives above a figure that has not itself changed. Each numeric character sits in its own clip box rather than relying on the root, so gaining a line costs the number nothing — every digit keeps its identity and its place.", + tags: ["number", "multiline"], + values: ["1,234", "Total\n1,234"], + minLines: 2, + verify: (t) => + combineResults( + verifyTextPlaces(t, "1,234", "Total\n1,234", [ + [2, 0], + [3, 1], + [4, 2], + [5, 3], + [6, 4], + ]), + verifyKindsAfterMorph(t, "1,234", "Total\n1,234", [ + undefined, + undefined, + "digit", + "symbol", + "digit", + "digit", + "digit", + ]), + ), + }, + { + label: "A number changes as a line arrives", + description: + "The second line and a new figure land on the same morph. Place matching still applies across the line change, and every digit here is different — so the group separator is the one thing that carries.", + tags: ["number", "multiline", "place"], + values: ["1,234", "Total\n5,678"], + minLines: 2, + verify: (t) => + verifyTextPlaces(t, "1,234", "Total\n5,678", [ + [2, null], + [3, 1], + ]), + }, + { + label: "A number on a middle line updates", + description: + "The figure between two other lines is replaced while they hold still, newlines included. A digit slides one line box, not the height of the whole block, and its slot is what it disappears behind — so the lines around it are never touched.", + tags: ["number", "multiline", "place"], + values: ["a\n1,234\nb", "a\n5,678\nb"], + minLines: 3, + verify: (t) => + combineResults( + verifyTextPlaces(t, "a\n1,234\nb", "a\n5,678\nb", [ + [0, 0], + [1, 1], + [3, 3], + [7, 7], + [8, 8], + ]), + verifyKindsAfterMorph(t, "a\n1,234\nb", "a\n5,678\nb", [ + undefined, + undefined, + "digit", + "symbol", + "digit", + "digit", + "digit", + undefined, + undefined, + ]), + ), + }, + { + label: "A number swaps lines with its label", + description: + "The figure moves from the bottom line to the top and the label goes the other way. Both are matched as whole words across the newline, so they trade places intact rather than being rebuilt.", + tags: ["number", "multiline"], + values: ["text\n1,234", "1,234\ntext"], + minLines: 2, + verify: (t) => + verifyTextPlaces(t, "text\n1,234", "1,234\ntext", [ + [0, 2], + [1, 3], + [4, 6], + [6, 0], + ]), + }, { label: "Dates stay text", description: diff --git a/packages/test-cases/src/index.ts b/packages/test-cases/src/index.ts index 3609e54..93190b9 100644 --- a/packages/test-cases/src/index.ts +++ b/packages/test-cases/src/index.ts @@ -8,6 +8,7 @@ export { verifyCycleStability, verifyGraphemeMorph, verifyKinds, + verifyKindsAfterMorph, verifyNoMorph, verifyTextPlaces, verifyWordAbsent, diff --git a/packages/test-cases/src/verify.ts b/packages/test-cases/src/verify.ts index 354cf85..1a358ab 100644 --- a/packages/test-cases/src/verify.ts +++ b/packages/test-cases/src/verify.ts @@ -196,3 +196,33 @@ export function verifyKinds( : `expected ${render(expected)}, got ${render(kinds)}`, }; } + +/** + * The kinds a value ends up with after morphing into it. + * + * Distinct from `verifyKinds`, which segments a value from scratch. A morph can + * inherit whole words from the value before it, kinds and all, so the two paths + * can disagree about the same string — and only this one sees it. + */ +export function verifyKindsAfterMorph( + t: TorphApi, + from: string, + to: string, + expected: (string | undefined)[], +): Result { + const { segments } = t.diffSegments(t.segmentText(from, L), to, L); + const kinds = segments.map((s) => s.kind); + const pass = + kinds.length === expected.length && + kinds.every((kind, i) => kind === expected[i]); + + const render = (list: (string | undefined)[]) => + `[${list.map((k) => k ?? "text").join(",")}]`; + + return { + pass, + detail: pass + ? `${render(kinds)} as expected` + : `expected ${render(expected)}, got ${render(kinds)}`, + }; +} diff --git a/packages/torph/src/lib/text-morph/__tests__/engine.test.ts b/packages/torph/src/lib/text-morph/__tests__/engine.test.ts index 5755346..dbfce08 100644 --- a/packages/torph/src/lib/text-morph/__tests__/engine.test.ts +++ b/packages/torph/src/lib/text-morph/__tests__/engine.test.ts @@ -2,7 +2,12 @@ import { describe, it, expect, afterEach, beforeAll } from "vitest"; import { TextMorph } from "../index"; import type { TextMorphOptions } from "../types"; -import { ATTR_EXITING, ATTR_ID, ATTR_KIND } from "../../utils/constants"; +import { + ATTR_EXITING, + ATTR_ID, + ATTR_KIND, + ATTR_SLOT, +} from "../../utils/constants"; // The segmentation and diff suites assert what a morph *should* be. This one // asserts what the engine does with that: which of the two animation families @@ -32,8 +37,20 @@ beforeAll(() => { } as typeof Element.prototype.animate; }); -type Recorded = { id: string | null; keyframes: unknown; options: unknown }; +type Recorded = { + id: string | null; + onMover: boolean; + keyframes: unknown; + options: unknown; +}; +/** + * A numeric character's animation is split across two elements: the slot takes + * the FLIP correction and the span nested inside it takes the slide, so the + * slide happens behind the slot's clip. Only the slot carries an ID, so an + * animation is attributed to the nearest ancestor that has one and flagged with + * which of the two it landed on. + */ function recordAnimations() { const calls: Recorded[] = []; const original = Element.prototype.animate; @@ -43,7 +60,13 @@ function recordAnimations() { keyframes: unknown, options: unknown, ) { - calls.push({ id: this.getAttribute(ATTR_ID), keyframes, options }); + const owner = this.closest(`[${ATTR_ID}]`); + calls.push({ + id: owner?.getAttribute(ATTR_ID) ?? null, + onMover: owner !== this, + keyframes, + options, + }); return original.call(this, keyframes as never, options as never); } as typeof Element.prototype.animate; @@ -59,12 +82,21 @@ type Frame = { transform?: string; opacity?: number; offset?: number }; * keyframe pinned to one end of the timeline, a text morph is a pair walking * from an offset to `none`. */ -function motion(calls: Recorded[], id: string): string | null { - const call = calls.find((c) => { - if (c.id !== id) return false; +function transformCall( + calls: Recorded[], + id: string, + onMover: boolean, +): Recorded | undefined { + return calls.find((c) => { + if (c.id !== id || c.onMover !== onMover) return false; const frames = (Array.isArray(c.keyframes) ? c.keyframes : [c.keyframes]) as Frame[]; return frames.some((frame) => frame.transform !== undefined); }); +} + +/** What layout did to a character — the transform on the item itself. */ +function motion(calls: Recorded[], id: string): string | null { + const call = transformCall(calls, id, false); if (!call) return null; if (Array.isArray(call.keyframes)) { @@ -75,6 +107,14 @@ function motion(calls: Recorded[], id: string): string | null { return `${frame.transform} @${frame.offset}`; } +/** What the character did inside its slot — the block-axis slide. */ +function slide(calls: Recorded[], id: string): string | null { + const call = transformCall(calls, id, true); + if (!call) return null; + const frame = call.keyframes as Frame; + return `${frame.transform} @${frame.offset}`; +} + const slideFrom = (dy: number) => `translate(0px, ${dy}px) @0`; const slideOut = `translate(0px, ${SLIDE}px) @1`; const textEnter = "translate(0px, 0px) scale(0.95) → none"; @@ -171,8 +211,9 @@ describe("animation dispatch", () => { // Digits arrive from above and symbols from below, so a separator appearing // between them reads as a different event from the digit that displaced it. - expect(motion(calls, leading)).toBe(slideFrom(-SLIDE)); - expect(motion(calls, comma)).toBe(slideFrom(SLIDE)); + // Both happen on the nested span, behind the slot's clip. + expect(slide(calls, leading)).toBe(slideFrom(-SLIDE)); + expect(slide(calls, comma)).toBe(slideFrom(SLIDE)); }); it("leaves a digit that held its place untouched", () => { @@ -185,8 +226,12 @@ describe("animation dispatch", () => { restore(); // Place matching keeps 2, 3 and 4 in their columns. With no delta to - // correct, animating them at all would be motion the number does not have. - for (const id of held) expect(motion(calls, id)).toBeNull(); + // correct and nothing to slide, animating them at all — on either the slot + // or the character inside it — would be motion the number does not have. + for (const id of held) { + expect(motion(calls, id), `slot ${id}`).toBeNull(); + expect(slide(calls, id), `mover ${id}`).toBeNull(); + } }); it("sends words through the text morph, not the slide", () => { @@ -225,86 +270,141 @@ describe("animation dispatch", () => { morph.update("hello"); restore(); - for (const id of digits) expect(motion(calls, id)).toBe(slideOut); + for (const id of digits) expect(slide(calls, id)).toBe(slideOut); }); }); -describe("the block-axis mask", () => { - it("is installed only once a value holds a number, and held until it stops", () => { +describe("the clip a slide happens behind", () => { + it("gives every numeric character its own box, and nothing else one", () => { const { element, morph } = mount(); + morph.update("3 apples"); - morph.update("hello"); - expect(element.style.overflowY).toBe(""); - expect(element.style.getPropertyValue("mask-image")).toBe(""); + const slots = Array.from(element.children).filter((child) => + child.hasAttribute(ATTR_SLOT), + ); - morph.update("5 apples"); - expect(element.style.overflowY).toBe("clip"); - expect(element.style.getPropertyValue("mask-image")).toContain("--torph-fade"); + // Exactly the digit, and the character itself moved into a nested span so + // the slot around it has something to clip against. + expect(slots.map((s) => s.textContent)).toEqual(["3"]); + expect(slots[0]!.children.length).toBe(1); + expect(slots[0]!.firstElementChild!.textContent).toBe("3"); + }); - // The digit is mid-exit on this update — dropping the mask now would let it - // slide out in full view. - morph.update("hello"); - expect(element.style.overflowY).toBe("clip"); + it("leaves the root unclipped, which is the whole reason slots exist", () => { + const { element, morph } = mount(); + morph.update("a\n1,234\nb"); - morph.update("goodbye"); + // The root spans every line of the value, so clipping there would bound + // only the first line's top and the last line's bottom — a digit on the + // middle line would slide over its neighbour in plain view. expect(element.style.overflowY).toBe(""); expect(element.style.getPropertyValue("mask-image")).toBe(""); }); - it("is cleared on destroy", () => { + it("clips and fades the slot from the stylesheet, not per element", () => { const { element, morph } = mount(); morph.update("$5"); - expect(element.style.overflowY).toBe("clip"); - morph.destroy(); - mounted.pop(); + const slot = element.querySelector(`[${ATTR_SLOT}]`)!; + expect(slot.getAttribute("style")).toBeNull(); - expect(element.style.overflowY).toBe(""); - expect(element.style.getPropertyValue("mask-image")).toBe(""); + const css = document.querySelector("style[data-torph]")!.textContent!; + expect(css).toContain(`[${ATTR_SLOT}]`); + expect(css).toContain("overflow-y: clip"); + expect(css).toContain("--torph-fade"); }); }); describe("opting out", () => { - it("numbers: false leaves digits as text and never masks", () => { + it("numbers: false leaves digits as text, with no slots to slide in", () => { const { element, morph } = mount({ numbers: false }); morph.update("hello"); morph.update("$1,234"); expect(shape(element).every((entry) => entry.endsWith(":text"))).toBe(true); - expect(element.style.overflowY).toBe(""); + expect(element.querySelector(`[${ATTR_SLOT}]`)).toBeNull(); }); +}); - it("a multi-line value falls back to text", () => { +describe("numbers across line changes", () => { + // Each of these moves a value between one line and several with a figure in + // it. A digit's clip box is its own slot rather than the root, so none of it + // should make any difference to how the number behaves. + const steps = [ + "1,234", + "1,234\ntotal", // gains a line below + "Total\n5,678", // gains a line above and changes at once + "a\n1,234\nb", // figure on a middle line + "a\n5,678\nb", // middle line updates + "text\n1,234", + "1,234\ntext", // swaps lines with its label + "5,678", // back onto one line + ]; + + it("keeps the figure a number on every line", () => { const { element, morph } = mount(); - morph.update("Total"); - morph.update("Total\n1,234"); - expect(shape(element).some((entry) => entry.includes(":digit"))).toBe(false); - expect(element.style.overflowY).toBe(""); + for (const value of steps) { + morph.update(value); + + const digits = live(element).filter((c) => c.kind !== null); + expect(digits.length, `${JSON.stringify(value)} lost its number`).toBe(5); + + const ids = live(element).map((c) => c.id); + const duplicate = ids.find((id, i) => ids.indexOf(id) !== i); + expect(duplicate, `${JSON.stringify(value)} repeats an ID`).toBeUndefined(); + + // `
` carries the line break and holds no text of its own. + expect(rendered(element)).toBe(value.replace(/\n/g, "")); + } }); -}); -describe("numeric values", () => { - it("formats through locale and decimals", () => { - const { element, morph } = mount({ locale: "en", decimals: 2 }); - morph.update(1234.5); + it("slides one line box, not the height of the whole block", () => { + const { element, morph } = mount(); + // happy-dom has no layout, so the block height is stated rather than + // measured. Three lines of it is what a digit must not travel. + Object.defineProperty(element, "offsetHeight", { + value: 60, + configurable: true, + }); - expect(rendered(element)).toBe("1,234.50"); + morph.update("1,234"); + let recorder = recordAnimations(); + morph.update("5,678"); + recorder.restore(); + expect(slide(recorder.calls, idOf(element, "5"))).toBe(slideFrom(-60)); + + morph.update("a\n1,234\nb"); + recorder = recordAnimations(); + morph.update("a\n5,678\nb"); + recorder.restore(); + + // Same 60px block, now three lines tall: the digit travels one of them. + expect(slide(recorder.calls, idOf(element, "5"))).toBe(slideFrom(-20)); }); - it("takes a caret for a value that is a single number", () => { + it("never hands the lines around the figure a slide", () => { const { element, morph } = mount(); - morph.update("$4"); - const dollar = idOf(element, "$"); - const four = idOf(element, "4"); + morph.update("a\n1,234\nb"); + const above = idOf(element, "a"); + const below = idOf(element, "b"); - // Typing "2" after the 4: place matching would read the 4 as having changed - // magnitude, the caret says it simply stayed where it was. - morph.update("$42", 3); + const { calls, restore } = recordAnimations(); + morph.update("a\n5,678\nb"); + restore(); - const after = live(element); - expect(after.map((c) => c.id)).toEqual([dollar, four, after[2]!.id]); - expect(rendered(element)).toBe("$42"); + // Whether they *moved* is a layout question happy-dom cannot answer — every + // rect here is zero, so every delta is too. What it can answer is whether + // they were treated as part of the number, which is what a leaked kind + // would do to them. + for (const id of [above, below]) { + expect(slide(calls, id), `mover ${id}`).toBeNull(); + } + expect( + live(element) + .filter((c) => c.text === "a" || c.text === "b") + .map((c) => c.kind), + ).toEqual([null, null]); }); }); diff --git a/packages/torph/src/lib/text-morph/index.ts b/packages/torph/src/lib/text-morph/index.ts index bf52572..4a34a4e 100644 --- a/packages/torph/src/lib/text-morph/index.ts +++ b/packages/torph/src/lib/text-morph/index.ts @@ -2,7 +2,6 @@ import type { TextMorphOptions } from "./types"; import { BASE_DEFAULTS, type Segment } from "../utils/types"; import { resolveEase } from "../utils/spring"; import { segmentText } from "./utils/segment"; -import { numbersAllowed } from "./utils/number"; import { type Measures, measure, @@ -23,12 +22,7 @@ import { } from "./utils/number-animate"; import { detachFromFlow, splitWordSpans, reconcileChildren } from "../utils/dom"; import { diffSegments } from "./utils/diff"; -import { - addStyles, - applyBlockFade, - clearBlockFade, - removeStyles, -} from "../utils/styles"; +import { addStyles, removeStyles } from "../utils/styles"; import { ATTR_ROOT, ATTR_DEBUG, @@ -67,8 +61,6 @@ export class TextMorph { private previousSegments: Segment[] = []; private isInitialRender = true; private reducedMotion: ReducedMotionState | null = null; - private fadeApplied = false; - private hadNumbers = false; constructor(options: TextMorphOptions) { const { ease: rawEase, ...rest } = { @@ -98,7 +90,6 @@ export class TextMorph { destroy() { this.reducedMotion?.destroy(); - clearBlockFade(this.element); clearContainerTransition(this.element); this.element.getAnimations().forEach((anim) => anim.cancel()); this.element.removeAttribute(ATTR_ROOT); @@ -157,10 +148,7 @@ export class TextMorph { const oldRect = element.getBoundingClientRect(); const oldWidth = oldRect.width; const oldHeight = oldRect.height; - // The block-axis travel of a digit is one line, so the line box measures it. - const slideDistance = element.offsetHeight || 20; - - const numbers = numbersAllowed(value, this.options.numbers !== false); + const numbers = this.options.numbers !== false; let segments: Segment[]; let splits: Map; @@ -179,8 +167,6 @@ export class TextMorph { splits = new Map(); } - this.applyFade(segments.some((segment) => segment.kind !== undefined)); - // Keep a zero-width space segment so the container always has in-flow // content, preserving the line box height during exit animations. const isEmptyTransition = segments.length === 0; @@ -214,6 +200,15 @@ export class TextMorph { this.currentMeasures = measure(this.element); + // One line's worth, not the whole block. Every line box is the same height + // here — the root is `white-space: nowrap`, so a line exists only where the + // value put a newline — which makes counting them exact. + const lineCount = segments.reduce( + (lines, segment) => (segment.string === "\n" ? lines + 1 : lines), + 1, + ); + const slideDistance = (element.offsetHeight || 20 * lineCount) / lineCount; + // First-frame positions have to be measured at the old width, not derived // from it — text-align has no effect on content that overflows. element.style.width = `${oldWidth}px`; @@ -284,22 +279,6 @@ export class TextMorph { } } - /** - * Held one update past the last number so digits on their way out are still - * masked while they slide, and only installed once a value actually contains - * one — a mask on every root would cost a stacking context for nothing. - */ - private applyFade(hasNumbers: boolean) { - const wanted = hasNumbers || this.hadNumbers; - this.hadNumbers = hasNumbers; - - if (wanted === this.fadeApplied) return; - this.fadeApplied = wanted; - - if (wanted) applyBlockFade(this.element); - else clearBlockFade(this.element); - } - private updateStyles( segments: Segment[], firstFrameMeasures: Measures, diff --git a/packages/torph/src/lib/text-morph/utils/diff.ts b/packages/torph/src/lib/text-morph/utils/diff.ts index 11af928..de9f119 100644 --- a/packages/torph/src/lib/text-morph/utils/diff.ts +++ b/packages/torph/src/lib/text-morph/utils/diff.ts @@ -6,7 +6,6 @@ import { decimalSeparator, hasDigit, isNumericWord, - numbersAllowed, numericSkeleton, segmentNumber, } from "./number"; @@ -166,7 +165,7 @@ export function diffSegments( const newHasNewlines = newText.includes("\n"); const oldWords = groupIntoWords(oldSegments); - const numbersOn = numbersAllowed(newText, options.numbers !== false); + const numbersOn = options.numbers !== false; const isNum = (word: string) => numbersOn && isNumericWord(word); const token = (word: string) => (isNum(word) ? NUMBER_TOKEN : word); diff --git a/packages/torph/src/lib/text-morph/utils/number-animate.ts b/packages/torph/src/lib/text-morph/utils/number-animate.ts index 789c803..50c295a 100644 --- a/packages/torph/src/lib/text-morph/utils/number-animate.ts +++ b/packages/torph/src/lib/text-morph/utils/number-animate.ts @@ -1,4 +1,5 @@ import { parseTranslate, cancelAnimations } from "../../utils/animate"; +import { moverOf } from "../../utils/dom"; /** * Fades are a share of the morph rather than a fixed length, and the outgoing @@ -6,15 +7,22 @@ import { parseTranslate, cancelAnimations } from "../../utils/animate"; * number, so it stays legible well into the slide, while the incoming digit * asserts itself early instead of ghosting in behind it. * - * The block-axis mask is doing most of the work here — both sets of characters - * are also being clipped as they cross the line box — so these only have to + * The slot's mask is doing most of the work here — both sets of characters are + * also being clipped as they cross their line box — so these only have to * soften the edges of that. */ const EXIT_FADE = 0.45; const ENTER_FADE = 0.25; +/** + * Every one of these splits the same way. The slot takes the FLIP correction, + * because that is what layout moved and what the next morph will measure; the + * character inside it takes the slide and the fade, because that is what has to + * happen behind a clip. Keeping the slide off the slot is what lets a digit + * travel a whole line box without the diff ever seeing it move. + */ export function animateNumberExit( - child: HTMLElement, + slot: HTMLElement, options: { dx: number; dy: number; @@ -24,38 +32,30 @@ export function animateNumberExit( }, ) { const { dx, dy, slideDistance, duration, ease } = options; + const mover = moverOf(slot); - child.animate( - { - transform: `translate(${dx}px, ${dy + slideDistance}px)`, - offset: 1, - }, - { - duration, - easing: ease, - fill: "both", - }, + slot.animate( + { transform: `translate(${dx}px, ${dy}px)`, offset: 1 }, + { duration, easing: ease, fill: "both" }, ); - const fadeAnimation = child.animate( - { - opacity: 0, - offset: 1, - }, - { - duration: duration * EXIT_FADE, - easing: "linear", - fill: "both", - }, + mover.animate( + { transform: `translate(0px, ${slideDistance}px)`, offset: 1 }, + { duration, easing: ease, fill: "both" }, + ); + + const fadeAnimation = mover.animate( + { opacity: 0, offset: 1 }, + { duration: duration * EXIT_FADE, easing: "linear", fill: "both" }, ); // Removal is the fade finishing, so the share above is also how long an - // exiting character stays in the DOM. - fadeAnimation.onfinish = () => child.remove(); + // exiting character stays in the DOM. The slot goes, not just its contents. + fadeAnimation.onfinish = () => slot.remove(); } export function animateNumberEnter( - child: HTMLElement, + slot: HTMLElement, options: { deltaX: number; deltaY: number; @@ -67,27 +67,24 @@ export function animateNumberEnter( ) { const { deltaX, deltaY, slideDistance, kind, duration, ease } = options; - const prev = cancelAnimations(child); + animateNumberPersist(slot, { deltaX, deltaY, duration, ease }); - const slideOffset = kind === "digit" ? -slideDistance : slideDistance; - const startX = deltaX + prev.tx; - const startY = deltaY + prev.ty + slideOffset; + const mover = moverOf(slot); + const prev = cancelAnimations(mover); - child.animate( - { - transform: `translate(${startX}px, ${startY}px)`, - offset: 0, - }, - { - duration, - easing: ease, - fill: "both", - }, + // Digits arrive from above and the symbols between them from below, so a + // separator appearing mid-number reads as its own event rather than as one + // more digit. + const from = kind === "digit" ? -slideDistance : slideDistance; + + mover.animate( + { transform: `translate(0px, ${prev.ty + from}px)`, offset: 0 }, + { duration, easing: ease, fill: "both" }, ); const startOpacity = prev.opacity >= 1 ? 0 : prev.opacity; if (startOpacity < 1) { - child.animate([{ opacity: startOpacity }, { opacity: 1 }], { + mover.animate([{ opacity: startOpacity }, { opacity: 1 }], { duration: duration * ENTER_FADE, easing: "linear", fill: "both", @@ -96,7 +93,7 @@ export function animateNumberEnter( } export function animateNumberPersist( - child: HTMLElement, + slot: HTMLElement, options: { deltaX: number; deltaY: number; @@ -106,23 +103,16 @@ export function animateNumberPersist( ) { const { deltaX, deltaY, duration, ease } = options; - const { tx, ty } = parseTranslate(child); - child.getAnimations().forEach((a) => a.cancel()); + const { tx, ty } = parseTranslate(slot); + slot.getAnimations().forEach((a) => a.cancel()); const startX = deltaX + tx; const startY = deltaY + ty; if (startX === 0 && startY === 0) return; - child.animate( - { - transform: `translate(${startX}px, ${startY}px)`, - offset: 0, - }, - { - duration, - easing: ease, - fill: "both", - }, + slot.animate( + { transform: `translate(${startX}px, ${startY}px)`, offset: 0 }, + { duration, easing: ease, fill: "both" }, ); } diff --git a/packages/torph/src/lib/text-morph/utils/number.ts b/packages/torph/src/lib/text-morph/utils/number.ts index 7146f6b..d9b7811 100644 --- a/packages/torph/src/lib/text-morph/utils/number.ts +++ b/packages/torph/src/lib/text-morph/utils/number.ts @@ -44,19 +44,6 @@ export function numericSkeleton(word: string): string { return out; } -/** - * Whether numeric morphing applies to a value at all. - * - * A multi-line root is taller than one line, so the block-axis mask that hides a - * sliding digit has nothing to hide it against — the digit would simply travel - * over the line below. The rule lives here because `segmentText`, `diffSegments` - * and the engine all have to reach the same verdict; two of them disagreeing - * puts a kind on a segment the root cannot clip. - */ -export function numbersAllowed(value: string, enabled = true): boolean { - return enabled && !value.includes("\n"); -} - /** Separators that can appear *between* digits without ending the number. */ const CORE_SEPARATORS = ".,'\u00A0\u202F\u2009\u2007"; const PREFIX_CHARS = "+-\u2212(#"; diff --git a/packages/torph/src/lib/text-morph/utils/segment.ts b/packages/torph/src/lib/text-morph/utils/segment.ts index 023965e..368399f 100644 --- a/packages/torph/src/lib/text-morph/utils/segment.ts +++ b/packages/torph/src/lib/text-morph/utils/segment.ts @@ -1,6 +1,6 @@ export type { Segment } from "../../utils/types"; import type { Segment } from "../../utils/types"; -import { isNumericWord, numbersAllowed, segmentNumber } from "./number"; +import { isNumericWord, segmentNumber } from "./number"; // IDs are the identity used for FLIP tracking and DOM reconciliation, so a // collision makes two segments fight over one element and one of them silently @@ -107,7 +107,6 @@ export function segmentText( const hasNewlines = value.includes("\n"); const byWord = value.includes(" ") || hasNewlines; const alloc = createIdAllocator(); - const withNumbers = numbersAllowed(value, numbers); if (hasNewlines) { // `offset` is the character index into the full value, so IDs derived from @@ -130,11 +129,11 @@ export function segmentText( offset += line.length; }); - return withNumbers ? expandNumbers(allSegments) : allSegments; + return numbers ? expandNumbers(allSegments) : allSegments; } const segments = segmentLine(value, locale, byWord, 0, alloc); - return withNumbers ? expandNumbers(segments) : segments; + return numbers ? expandNumbers(segments) : segments; } function segmentLine( diff --git a/packages/torph/src/lib/utils/constants.ts b/packages/torph/src/lib/utils/constants.ts index f5aecb8..6d00d8b 100644 --- a/packages/torph/src/lib/utils/constants.ts +++ b/packages/torph/src/lib/utils/constants.ts @@ -2,6 +2,7 @@ export const ATTR_ROOT = "torph-root"; export const ATTR_ITEM = "torph-item"; export const ATTR_ID = "torph-id"; export const ATTR_KIND = "torph-kind"; +export const ATTR_SLOT = "torph-slot"; export const ATTR_EXITING = "torph-exiting"; export const ATTR_DEBUG = "torph-debug"; export const EMPTY_ID = "empty"; diff --git a/packages/torph/src/lib/utils/dom.ts b/packages/torph/src/lib/utils/dom.ts index 8f7f18f..b40c59d 100644 --- a/packages/torph/src/lib/utils/dom.ts +++ b/packages/torph/src/lib/utils/dom.ts @@ -1,5 +1,11 @@ import type { Segment } from "./types"; -import { ATTR_EXITING, ATTR_ID, ATTR_ITEM, ATTR_KIND } from "./constants"; +import { + ATTR_EXITING, + ATTR_ID, + ATTR_ITEM, + ATTR_KIND, + ATTR_SLOT, +} from "./constants"; export function detachFromFlow( container: HTMLElement, @@ -73,19 +79,54 @@ export function splitWordSpans( const span = document.createElement("span"); span.setAttribute(ATTR_ITEM, ""); span.setAttribute(ATTR_ID, seg.id); - applyKind(span, seg); - span.textContent = seg.string; + syncSlot(span, seg); child.before(span); } child.remove(); } } -// An exit outlives the segment that described it — the element is all that is -// left by the time it animates — so the kind has to live on the element. -function applyKind(element: HTMLElement, segment: Segment) { - if (segment.kind) element.setAttribute(ATTR_KIND, segment.kind); - else element.removeAttribute(ATTR_KIND); +/** + * Gives a numeric character the nested box its slide needs, and takes it away + * again when the same character stops being one. + * + * The kind is written to the element because an exit outlives the segment that + * described it — by the time it animates, the element is all that is left. + * + * Both directions have to work on an element being reused: a figure that gains + * a second line becomes text and has to shed its slot, and gets it back when + * the value returns to one line. + */ +function syncSlot(element: HTMLElement, segment: Segment) { + if (!segment.kind) { + element.removeAttribute(ATTR_KIND); + element.removeAttribute(ATTR_SLOT); + // Also discards the inner span, if this element had one. + element.textContent = segment.string; + return; + } + + element.setAttribute(ATTR_KIND, segment.kind); + element.setAttribute(ATTR_SLOT, ""); + + let inner = element.firstElementChild as HTMLElement | null; + if (!inner) { + element.textContent = ""; + inner = document.createElement("span"); + element.appendChild(inner); + } + inner.textContent = segment.string; +} + +/** + * The box the slide is applied to. For a slot that is the nested span, so the + * movement is clipped by the slot around it; for anything else the element is + * its own mover. + */ +export function moverOf(element: HTMLElement): HTMLElement { + return element.hasAttribute(ATTR_SLOT) + ? ((element.firstElementChild as HTMLElement | null) ?? element) + : element; } export function reconcileChildren( @@ -129,15 +170,13 @@ export function reconcileChildren( } if (existing && existing.tagName !== "BR") { - existing.textContent = segment.string; - applyKind(existing, segment); + syncSlot(existing, segment); element.appendChild(existing); } else { const span = document.createElement("span"); span.setAttribute(ATTR_ITEM, ""); span.setAttribute(ATTR_ID, segment.id); - applyKind(span, segment); - span.textContent = segment.string; + syncSlot(span, segment); element.appendChild(span); } }); diff --git a/packages/torph/src/lib/utils/styles.ts b/packages/torph/src/lib/utils/styles.ts index cac2359..d051da9 100644 --- a/packages/torph/src/lib/utils/styles.ts +++ b/packages/torph/src/lib/utils/styles.ts @@ -1,4 +1,4 @@ -import { ATTR_ROOT, ATTR_ITEM, ATTR_DEBUG } from "./constants"; +import { ATTR_ROOT, ATTR_ITEM, ATTR_DEBUG, ATTR_SLOT } from "./constants"; const TORPH_CSS = ` [${ATTR_ROOT}] { @@ -18,6 +18,62 @@ const TORPH_CSS = ` opacity: 1; } +/* + * A numeric character slides a whole line box to arrive or leave, so it needs + * something to disappear behind. That has to be its own box rather than the + * root: the root spans every line of the value, so clipping there only bounds + * the first line's top and the last line's bottom, and a digit on any line + * between them would slide over its neighbour in plain view. + * + * The slot's height is the line box, and the transform lives on the child, so + * the slide never touches the slot's own rect. The FLIP pass measures slots and + * is oblivious to where the character inside one has got to. + */ +[${ATTR_SLOT}] { + overflow-x: visible; + overflow-y: clip; +} + +[${ATTR_SLOT}] > span { + display: inline-block; + will-change: opacity, transform; +} + +/* + * Softens the clip above into a gradient, so a character dissolves across the + * edge of its line box instead of meeting a hard line. Positional rather than + * timed: how faint it is depends on where it has slid to, which keeps it in + * step with its own movement at any duration. + * + * The band is --torph-fade — set it to 0 for a hard edge. It has to eat + * into the box, because the clip is what bounds the block axis and it trims at + * the border box: a gradient reaching past that edge lies in territory the clip + * has already taken, and the fade is never seen. The cost is that the band also + * dims anything legitimately sitting in it, so at a tight line-height a + * descender's tip goes faint. + * + * no-clip is why the fallback exists. A mask layer is otherwise clipped to + * the border box, which would hide any glyph overhanging its slot; repeat-x + * then carries the same profile across that overhang. + */ +@supports (mask-clip: no-clip) or (-webkit-mask-clip: no-clip) { + [${ATTR_SLOT}] { + --torph-mask: linear-gradient( + to bottom, + transparent, + #000 var(--torph-fade, 0.15em), + #000 calc(100% - var(--torph-fade, 0.15em)), + transparent + ); + -webkit-mask-image: var(--torph-mask); + mask-image: var(--torph-mask); + -webkit-mask-repeat: repeat-x; + mask-repeat: repeat-x; + -webkit-mask-clip: no-clip; + mask-clip: no-clip; + } +} + [${ATTR_ROOT}][${ATTR_DEBUG}] { outline: 2px solid magenta; [${ATTR_ITEM}] { @@ -46,90 +102,3 @@ export function removeStyles() { styleEl.remove(); styleEl = null; } - -const MASK_PROPERTIES = [ - "mask-image", - "mask-repeat", - "mask-clip", - "mask-size", - "mask-position", -] as const; - -/** - * Digits slide vertically past the line box on enter and exit, so the block - * axis is masked. The inline axis must stay visible: the container spends the - * whole duration animating to its new width, and clipping it would mask every - * character sitting beyond the old width until the size transition catches up. - * - * `clip` is what makes that split legal — `overflow-x: visible` next to - * `overflow-y: hidden` computes to `auto`, which would scroll instead. - */ -function clipBlockAxis(element: HTMLElement) { - if (CSS.supports("overflow", "clip")) { - element.style.overflowX = "visible"; - element.style.overflowY = "clip"; - } else { - element.style.overflow = "hidden"; - } -} - -/** - * Softens the block-axis clip into a gradient, so characters dissolve across - * the edge of the line box instead of meeting a hard line. Positional rather - * than timed: how faint a character is depends on where it has slid to, which - * keeps it in step with its own movement at any duration. - * - * The band is `--torph-fade` on the root — set it to `0` for a hard edge. - * - * The tile is grown past the line box by `--torph-overshoot` so the fade - * begins *outside* it. Digits never needed that, but this root now holds - * arbitrary text: at a tight `line-height` a descender or a diacritic overflows - * the line box, and a band flush with the edge would ghost the tail of every - * "g" in the sentence. - * - * `no-clip` is load-bearing. A mask layer is otherwise clipped to the border - * box, which would hide every character sitting beyond the container's - * animating width — exactly what the visible inline axis exists to show. - * `repeat-x` then carries the same profile across that overflow, while the - * block axis stays a single tile so anything above or below the box is masked - * out. Without `no-clip` the mask would cost more than it gives, so the hard - * clip stands in. - */ -export function applyBlockFade(element: HTMLElement) { - clipBlockAxis(element); - - if ( - !CSS.supports("mask-clip", "no-clip") && - !CSS.supports("-webkit-mask-clip", "no-clip") - ) { - return; - } - - const fade = "var(--torph-fade, 0.15em)"; - const overshoot = "var(--torph-overshoot, 0.25em)"; - - setMaskProperty( - element, - "mask-image", - `linear-gradient(to bottom, transparent, #000 ${fade}, #000 calc(100% - ${fade}), transparent)`, - ); - setMaskProperty(element, "mask-repeat", "repeat-x"); - setMaskProperty(element, "mask-clip", "no-clip"); - setMaskProperty(element, "mask-size", `100% calc(100% + 2 * ${overshoot})`); - setMaskProperty(element, "mask-position", "center"); -} - -export function clearBlockFade(element: HTMLElement) { - element.style.overflow = ""; - element.style.overflowX = ""; - element.style.overflowY = ""; - MASK_PROPERTIES.forEach((property) => { - element.style.removeProperty(property); - element.style.removeProperty(`-webkit-${property}`); - }); -} - -function setMaskProperty(element: HTMLElement, property: string, value: string) { - element.style.setProperty(property, value); - element.style.setProperty(`-webkit-${property}`, value); -} From 6195a6dbf3c379bf5cfff3f797890f7f16cffb02 Mon Sep 17 00:00:00 2001 From: Lochie Axon Date: Wed, 2 Sep 2026 13:47:51 +1000 Subject: [PATCH 08/10] fix: iOS jitter --- .../src/lib/text-morph/__tests__/engine.test.ts | 6 +++++- packages/torph/src/lib/utils/styles.ts | 15 +++++++++++++-- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/packages/torph/src/lib/text-morph/__tests__/engine.test.ts b/packages/torph/src/lib/text-morph/__tests__/engine.test.ts index dbfce08..21dba0b 100644 --- a/packages/torph/src/lib/text-morph/__tests__/engine.test.ts +++ b/packages/torph/src/lib/text-morph/__tests__/engine.test.ts @@ -310,8 +310,12 @@ describe("the clip a slide happens behind", () => { const css = document.querySelector("style[data-torph]")!.textContent!; expect(css).toContain(`[${ATTR_SLOT}]`); - expect(css).toContain("overflow-y: clip"); expect(css).toContain("--torph-fade"); + + // clip-path, not overflow: overflow would synthesize the slot's baseline to + // its bottom margin edge and lift every digit off the line the words sit on. + expect(css).toContain("clip-path: inset("); + expect(css).not.toContain("overflow-y: clip"); }); }); diff --git a/packages/torph/src/lib/utils/styles.ts b/packages/torph/src/lib/utils/styles.ts index d051da9..458eee8 100644 --- a/packages/torph/src/lib/utils/styles.ts +++ b/packages/torph/src/lib/utils/styles.ts @@ -28,10 +28,21 @@ const TORPH_CSS = ` * The slot's height is the line box, and the transform lives on the child, so * the slide never touches the slot's own rect. The FLIP pass measures slots and * is oblivious to where the character inside one has got to. + * + * clip-path rather than overflow, because overflow would move the slot's + * baseline. An inline-block whose overflow computes to anything but visible has + * its baseline synthesized to the bottom margin edge (CSS 2.1 10.8.1), so every + * digit would hang from its own bottom edge while the words beside it sat on + * the text baseline, and the taller line box would drag anything measuring the + * root's height along with it. Whether overflow: clip triggers that rule is + * read differently by different engines, which is the worst version of it. + * + * The inline axis is left open. A digit does not move horizontally inside its + * slot, so the only thing out there is glyph overhang, and clipping it would + * shave italics and accents for nothing. */ [${ATTR_SLOT}] { - overflow-x: visible; - overflow-y: clip; + clip-path: inset(0 -100vw); } [${ATTR_SLOT}] > span { From 82109160409e1f274cb707515f9a3f79a61d3351 Mon Sep 17 00:00:00 2001 From: Lochie Axon Date: Wed, 2 Sep 2026 14:09:13 +1000 Subject: [PATCH 09/10] more cleanup --- .../lib/text-morph/__tests__/engine.test.ts | 17 ++++++++++ packages/torph/src/lib/text-morph/index.ts | 6 ++-- packages/torph/src/lib/utils/animate.ts | 23 ++++++++++--- .../surfaces/playground/input-demo/index.tsx | 13 ++++--- .../playground/input-demo/styles.module.scss | 34 +++++++------------ .../surfaces/playground/styles.module.scss | 11 +++++- 6 files changed, 66 insertions(+), 38 deletions(-) diff --git a/packages/torph/src/lib/text-morph/__tests__/engine.test.ts b/packages/torph/src/lib/text-morph/__tests__/engine.test.ts index 21dba0b..262663a 100644 --- a/packages/torph/src/lib/text-morph/__tests__/engine.test.ts +++ b/packages/torph/src/lib/text-morph/__tests__/engine.test.ts @@ -447,6 +447,23 @@ describe("invariants across a chained morph", () => { }); }); +describe("author sizing", () => { + it("leaves no inline width or height behind for CSS to fight", () => { + const { element, morph } = mount(); + + morph.update("hello"); + morph.update("hello world"); + morph.update("$1,234"); + + // An inline style outranks the page's own rules, so anything left here is + // permanent. A root that its CSS puts at `width: 100%` has to still be at + // `width: 100%` afterwards, or a `text-align` on it has no width left to + // align within — which is exactly how a left/centre/right control dies. + expect(element.style.width).toBe(""); + expect(element.style.height).toBe(""); + }); +}); + describe("disabled", () => { it("writes the value straight to the element", () => { const { element, morph } = mount({ disabled: true }); diff --git a/packages/torph/src/lib/text-morph/index.ts b/packages/torph/src/lib/text-morph/index.ts index 4a34a4e..2a00359 100644 --- a/packages/torph/src/lib/text-morph/index.ts +++ b/packages/torph/src/lib/text-morph/index.ts @@ -214,7 +214,7 @@ export class TextMorph { element.style.width = `${oldWidth}px`; void element.offsetWidth; const firstFrameMeasures = measure(this.element); - element.style.width = "auto"; + element.style.width = ""; this.updateStyles(segments, firstFrameMeasures, slideDistance); @@ -252,8 +252,8 @@ export class TextMorph { if (this.isInitialRender) { this.isInitialRender = false; - element.style.width = "auto"; - element.style.height = "auto"; + element.style.width = ""; + element.style.height = ""; return; } diff --git a/packages/torph/src/lib/utils/animate.ts b/packages/torph/src/lib/utils/animate.ts index 67846f8..cb78ad7 100644 --- a/packages/torph/src/lib/utils/animate.ts +++ b/packages/torph/src/lib/utils/animate.ts @@ -34,9 +34,19 @@ type PendingTransition = { const pending = new WeakMap(); +/** + * Releases the size back to whatever the author's CSS says, rather than pinning + * it to `auto`. + * + * An inline style outranks any stylesheet rule, so writing `auto` here does not + * restore anything — it overrides the page for good. A root told to be + * `width: 100%` by its own CSS would shrink to its content on the first morph + * and stay there, taking any `text-align` on it with it, because there is no + * longer any spare width to align within. + */ function restoreSize(element: HTMLElement) { - element.style.width = "auto"; - element.style.height = "auto"; + element.style.width = ""; + element.style.height = ""; element.style.transitionProperty = ""; } @@ -77,10 +87,13 @@ export function transitionContainerSize( return; } - // WAAPI drives the size instead, so it shares a start time with the items + // WAAPI drives the size instead, so it shares a start time with the items. + // Cleared rather than set to `auto`, so the target measured here is the size + // the author's CSS actually asks for — a root pinned to `width: 100%` should + // animate to that, which is to say not at all. element.style.transitionProperty = "none"; - element.style.width = "auto"; - element.style.height = "auto"; + element.style.width = ""; + element.style.height = ""; void element.offsetWidth; const newRect = element.getBoundingClientRect(); diff --git a/site/src/surfaces/playground/input-demo/index.tsx b/site/src/surfaces/playground/input-demo/index.tsx index 3947558..985b345 100644 --- a/site/src/surfaces/playground/input-demo/index.tsx +++ b/site/src/surfaces/playground/input-demo/index.tsx @@ -4,29 +4,28 @@ import { TextMorph } from "torph/react"; import styles from "./styles.module.scss"; import React from "react"; -import { InputNumber } from "./input"; export const InputPlayground = () => { - const [query, setQuery] = React.useState(""); + const [query, setQuery] = React.useState(undefined); const [cursor, setCursor] = React.useState(); const inputRef = React.useRef(null); return (
- +
+ {query} +
{ - setQuery(e.target.value); setCursor(inputRef.current?.selectionStart ?? undefined); + setQuery(e.target.valueAsNumber); }} /> -
- {query} -
); diff --git a/site/src/surfaces/playground/input-demo/styles.module.scss b/site/src/surfaces/playground/input-demo/styles.module.scss index a234604..721a4a0 100644 --- a/site/src/surfaces/playground/input-demo/styles.module.scss +++ b/site/src/surfaces/playground/input-demo/styles.module.scss @@ -9,32 +9,22 @@ background: var(--body-light); } +.output { + font-size: 3rem; + text-align: center; +} + .input { position: relative; - padding: 0.5rem 1rem; - font-family: var(--font-secondary); - font-size: 2rem; - font-weight: 500; - color: #ffffff; - border-radius: 0.5rem; - border: 1px solid #363636; input { - all: unset; - position: relative; - z-index: 1; width: 100%; - color: transparent; - caret-color: white; - font: inherit; - } - - .output { - position: absolute; - inset: 0; - padding: inherit; - pointer-events: none; - display: flex; - align-items: center; + padding: 0.5rem 1rem; + font-family: var(--font-secondary); + font-size: 2rem; + font-weight: 500; + color: #ffffff; + border-radius: 0.5rem; + border: 1px solid #363636; } } diff --git a/site/src/surfaces/playground/styles.module.scss b/site/src/surfaces/playground/styles.module.scss index eb5ae77..d147cc0 100644 --- a/site/src/surfaces/playground/styles.module.scss +++ b/site/src/surfaces/playground/styles.module.scss @@ -296,7 +296,16 @@ $fail: rgb(248, 113, 113); color: #fff; user-select: none; cursor: pointer; - overflow-x: auto; + + // A morph translates every item from where it was to where it lands, and + // transformed descendants count toward an ancestor's scrollable overflow — so + // `auto` flashed a scrollbar for the length of each transition. Every case is + // authored to fit the stage, so there is nothing here worth scrolling to. + // + // `clip` rather than `hidden` deliberately: `hidden` on one axis forces the + // other from `visible` to `auto`, which would make the stage scroll + // vertically instead. + overflow-x: clip; > * { width: 100%; From 7fa9a8bd9517837e0c117c1c196788c4ca6c21ed Mon Sep 17 00:00:00 2001 From: Lochie Axon Date: Wed, 2 Sep 2026 15:38:19 +1000 Subject: [PATCH 10/10] cleanup comma animations --- packages/test-cases/src/cases.ts | 23 ++++- packages/test-cases/src/number-cases.ts | 13 +-- .../lib/text-morph/__tests__/engine.test.ts | 4 +- .../utils/__tests__/number-segment.test.ts | 18 ++-- .../torph/src/lib/text-morph/utils/diff.ts | 45 +-------- .../torph/src/lib/text-morph/utils/number.ts | 91 ++++++++++++++++++- packages/torph/src/lib/utils/lcs.ts | 43 +++++++++ .../surfaces/playground/input-demo/index.tsx | 4 +- 8 files changed, 174 insertions(+), 67 deletions(-) create mode 100644 packages/torph/src/lib/utils/lcs.ts diff --git a/packages/test-cases/src/cases.ts b/packages/test-cases/src/cases.ts index 8f78680..c505a49 100644 --- a/packages/test-cases/src/cases.ts +++ b/packages/test-cases/src/cases.ts @@ -220,15 +220,15 @@ export const CASES: TestCase[] = [ { label: "Numbers morph by place", description: - "A numeric word goes to place matching, not character matching. The comma slides one group along and the affix holds; the leading 1 does not stay a leading 1, because it is a different magnitude now.", + "A numeric word goes to the number matcher, not to character matching. The affix holds and the run of digits the two values share carries across; the separator does not, because after a reshape of that size it is no longer the same boundary.", tags: ["number", "place"], values: ["$1,234", "$12,345,678", "$99"], align: "right", verify: (t) => verifyTextPlaces(t, "$1,234", "$12,345,678", [ [0, 0], - [1, null], - [7, 2], + [1, 1], + [7, null], ]), }, { @@ -324,6 +324,23 @@ export const CASES: TestCase[] = [ [6, 6], ]), }, + { + label: "A digit pushed into the middle", + description: + "1,234 gains a column and 123,456 gains a digit in the middle of itself. Both keep every digit they already had: when a number changes shape rather than just value, what carries is which digits are the same digits, so the run slides to its new magnitude instead of the whole figure being rebuilt around the newcomer.", + tags: ["number", "place", "enter"], + values: ["123,456", "1,234,576"], + verify: (t) => + verifyTextPlaces(t, "123,456", "1,234,576", [ + [0, 0], + [2, 1], + [3, 2], + [4, 4], + [6, 5], + [8, 6], + [7, null], + ]), + }, { label: "A number holds across a new line", description: diff --git a/packages/test-cases/src/number-cases.ts b/packages/test-cases/src/number-cases.ts index d5be68d..dd7c415 100644 --- a/packages/test-cases/src/number-cases.ts +++ b/packages/test-cases/src/number-cases.ts @@ -73,10 +73,11 @@ export const NUMBER_CASES: NumberCase[] = [ }, { label: "Separator slides back down", - description: "The same walk in reverse as the value shrinks a magnitude.", + description: + "The value loses a magnitude and keeps all four of the digits it still has, so they travel down together. The comma cannot travel with them — it would have to cross the run to reach its new group, the two passing in opposite directions — so it leaves and the new boundary arrives.", tags: ["separator", "exit"], values: ["12,345", "1,234"], - verify: (t) => verifyAlignment(t, "12,345", "1,234", [null, 2, null, null, null]), + verify: (t) => verifyAlignment(t, "12,345", "1,234", [0, null, 1, 3, 4]), }, { label: "Separator survives a round trip", @@ -157,7 +158,7 @@ export const NUMBER_CASES: NumberCase[] = [ { label: "German separators", description: - "de-DE groups with dots and pivots on the comma. Both separators should slide right by one place, same as the en case.", + "de-DE groups with dots and pivots on the comma. The decimal pivot holds, the integer digits carry across, and the group separator gives way to a new one rather than crossing them.", tags: ["locale", "separator"], values: ["1.234,56", "12.345,67"], locale: "de-DE", @@ -166,7 +167,7 @@ export const NUMBER_CASES: NumberCase[] = [ t, "1.234,56", "12.345,67", - [null, null, 1, null, null, null, 5, null, null], + [0, 2, null, 3, 4, null, 5, null, null], { decimalChar: "," }, ), }, @@ -187,7 +188,7 @@ export const NUMBER_CASES: NumberCase[] = [ { label: "French narrow spaces", description: - "fr-FR groups with a narrow no-break space (U+202F) rather than a glyph. It should walk out from the decimal comma exactly like any other separator.", + "fr-FR groups with a narrow no-break space (U+202F) rather than a glyph. It is treated as the separator it is — including giving way when the digits around it are re-shaped.", tags: ["locale", "separator", "space"], values: ["1\u202F234,56", "12\u202F345,67", "1\u202F234\u202F567,89"], locale: "fr-FR", @@ -196,7 +197,7 @@ export const NUMBER_CASES: NumberCase[] = [ t, "1\u202F234,56", "12\u202F345,67", - [null, null, 1, null, null, null, 5, null, null], + [0, 2, null, 3, 4, null, 5, null, null], { decimalChar: "," }, ), }, diff --git a/packages/torph/src/lib/text-morph/__tests__/engine.test.ts b/packages/torph/src/lib/text-morph/__tests__/engine.test.ts index 262663a..e0c1c8e 100644 --- a/packages/torph/src/lib/text-morph/__tests__/engine.test.ts +++ b/packages/torph/src/lib/text-morph/__tests__/engine.test.ts @@ -200,10 +200,10 @@ describe("kinds reach the DOM", () => { describe("animation dispatch", () => { it("slides a new digit down and a new symbol up", () => { const { element, morph } = mount(); - morph.update("1234"); + morph.update("999"); const { calls, restore } = recordAnimations(); - morph.update("1,234"); + morph.update("1,000"); restore(); const comma = idOf(element, ","); diff --git a/packages/torph/src/lib/text-morph/utils/__tests__/number-segment.test.ts b/packages/torph/src/lib/text-morph/utils/__tests__/number-segment.test.ts index 21db362..57e7f1a 100644 --- a/packages/torph/src/lib/text-morph/utils/__tests__/number-segment.test.ts +++ b/packages/torph/src/lib/text-morph/utils/__tests__/number-segment.test.ts @@ -32,7 +32,10 @@ describe("segmentNumber place matching", () => { }); it("slides separators the other way when the value shrinks", () => { - expect(alignment("12,345", "1,234")).toEqual([null, 2, null, null, null]); + // The integer side lost a column, so the four digits it still has are the + // four it had — they move down a magnitude rather than being replaced. The + // separator does not go with them; it would have to cross the run. + expect(alignment("12,345", "1,234")).toEqual([0, null, 1, 3, 4]); }); it("pins a currency prefix and the decimal point", () => { @@ -69,13 +72,16 @@ describe("segmentNumber place matching", () => { }); it("uses the locale's decimal separator as the pivot", () => { - // de-DE: dots group, the comma is the pivot. Both separators slide right. + // de-DE: dots group, the comma is the pivot. The pivot holds and the + // integer digits ride along; the group separator gives way rather than + // crossing them, and the fraction keeps its columns, so 56 → 67 replaces + // both of those. expect(alignment("1.234,56", "12.345,67", ",")).toEqual([ + 0, + 2, null, - null, - 1, - null, - null, + 3, + 4, null, 5, null, diff --git a/packages/torph/src/lib/text-morph/utils/diff.ts b/packages/torph/src/lib/text-morph/utils/diff.ts index de9f119..31ac485 100644 --- a/packages/torph/src/lib/text-morph/utils/diff.ts +++ b/packages/torph/src/lib/text-morph/utils/diff.ts @@ -1,5 +1,6 @@ import type { Segment } from "./segment"; import { createIdAllocator, groupIntoWords, segmentText } from "./segment"; +import { lcsIndices } from "../../utils/lcs"; import type { NumberSegment } from "./number"; import { classifyKind, @@ -79,50 +80,6 @@ type WordPlan = | { mode: "morph"; oi: number } | { mode: "number"; oi: number }; -/** - * Longest common subsequence, reported as paired indices into `a` and `b`. - * - * Built over suffixes and walked *forwards* so ties resolve to the earliest - * match — a repeated word keeps its element on the first occurrence. Walking - * backwards resolves them the other way, and the text flies across the block. - */ -function lcsIndices(a: string[], b: string[]): [number[], number[]] { - const m = a.length; - const n = b.length; - // dp[i][j] = length of the LCS of a[i..] and b[j..] - const dp: number[][] = Array.from({ length: m + 1 }, () => - Array(n + 1).fill(0), - ); - - for (let i = m - 1; i >= 0; i--) { - for (let j = n - 1; j >= 0; j--) { - dp[i]![j] = - a[i] === b[j] - ? dp[i + 1]![j + 1]! + 1 - : Math.max(dp[i + 1]![j]!, dp[i]![j + 1]!); - } - } - - const ai: number[] = []; - const bi: number[] = []; - let i = 0; - let j = 0; - while (i < m && j < n) { - if (a[i] === b[j]) { - ai.push(i); - bi.push(j); - i++; - j++; - } else if (dp[i + 1]![j]! >= dp[i]![j + 1]!) { - i++; - } else { - j++; - } - } - - return [ai, bi]; -} - function charSimilarity(a: string, b: string): number { if (a.length === 0 || b.length === 0) return 0; const [matched] = lcsIndices(a.split(""), b.split("")); diff --git a/packages/torph/src/lib/text-morph/utils/number.ts b/packages/torph/src/lib/text-morph/utils/number.ts index d9b7811..5f64dd8 100644 --- a/packages/torph/src/lib/text-morph/utils/number.ts +++ b/packages/torph/src/lib/text-morph/utils/number.ts @@ -1,4 +1,5 @@ import type { Segment, SegmentKind } from "../../utils/types"; +import { lcsIndices } from "../../utils/lcs"; export type NumberSegment = Segment & { kind: SegmentKind }; @@ -283,9 +284,20 @@ function placeMatch( const oldPivot = findPivot(oldChars, start, oldEnd, decimalChar); const newPivot = findPivot(newChars, start, newEnd, decimalChar); - for (let k = 1; oldPivot - k >= start && newPivot - k >= start; k++) { - if (oldChars[oldPivot - k] === newChars[newPivot - k]) { - matches.set(newPivot - k, oldPivot - k); + // A group separator holds its distance from the pivot — that is what slides + // it one group along on 999,999 → 1,000,000 instead of snapping it to the + // front — but only while the digits have not been re-shaped underneath it. + // + // Once a run of digits carries across, the separator would have to cross + // through that run to reach its new distance, the two passing in opposite + // directions. It is a boundary between groups, and after a reshape it is not + // the same boundary, so it leaves and a new one arrives. Where no digit + // persisted, nothing contradicts it and the slide is the only continuity the + // number has. + const reshaped = matchDigits(start, oldPivot, start, newPivot, true); + if (!reshaped) { + for (let k = 1; oldPivot - k >= start && newPivot - k >= start; k++) { + matchSeparator(oldPivot - k, newPivot - k); } } @@ -295,15 +307,84 @@ function placeMatch( matches.set(newPivot, oldPivot); for (let k = 1; oldPivot + k < oldEnd && newPivot + k < newEnd; k++) { - if (oldChars[oldPivot + k] === newChars[newPivot + k]) { - matches.set(newPivot + k, oldPivot + k); + matchSeparator(oldPivot + k, newPivot + k); + } + matchDigits(oldPivot + 1, oldEnd, newPivot + 1, newEnd, false); + } + + function matchSeparator(oldIndex: number, newIndex: number) { + const char = oldChars[oldIndex]!; + if (isDigit(char)) return; + if (char === newChars[newIndex]) matches.set(newIndex, oldIndex); + } + + /** + * Pairs the digits on one side of the pivot. + * + * Same count of them and a digit's column is its identity: the units digit is + * still the units digit, so they pair off by position and a changed digit + * simply rolls in place. + * + * A different count, on the integer side only, means the number changed shape + * rather than just value — it grew a column, or had a digit pushed into it — + * and the reading that matches is which digits are *the same digits*. They + * pair by longest common subsequence, so the run they share slides across to + * its new magnitude instead of every column being rebuilt around it. + * + * The fraction side never does this. Its columns are fixed by their distance + * from the decimal point, so lengthening or shortening it adds and removes + * digits at the far end without disturbing the ones already there: 1.5 → 1.25 + * is the tenths changing and a hundredths arriving, not the 5 sliding over. + * + * `towardsPivot` is the tie-break, and only repeated digits notice it. Four 1s + * becoming three has no single answer from the subsequence alone, and the one + * that reads correctly is the number losing its *leading* digit — so ties go + * to the end nearest the pivot, which is where a number is anchored. + */ + /** Returns whether digits carried across a change of shape. */ + function matchDigits( + oldFrom: number, + oldTo: number, + newFrom: number, + newTo: number, + towardsPivot: boolean, + ): boolean { + const oldIndices = digitIndices(oldChars, oldFrom, oldTo); + const newIndices = digitIndices(newChars, newFrom, newTo); + + if (oldIndices.length === newIndices.length || !towardsPivot) { + const pairs = Math.min(oldIndices.length, newIndices.length); + for (let k = 0; k < pairs; k++) { + const oi = oldIndices[k]!; + const ni = newIndices[k]!; + if (oldChars[oi] === newChars[ni]) matches.set(ni, oi); } + return false; } + + // Reversed, so the subsequence walk resolves its ties from the units end. + const oldRun = oldIndices.map((i) => oldChars[i]!).reverse(); + const newRun = newIndices.map((i) => newChars[i]!).reverse(); + const [ai, bi] = lcsIndices(oldRun, newRun); + + for (let k = 0; k < ai.length; k++) { + const oi = oldIndices[oldIndices.length - 1 - ai[k]!]!; + const ni = newIndices[newIndices.length - 1 - bi[k]!]!; + matches.set(ni, oi); + } + + return ai.length > 0; } return matches; } +function digitIndices(chars: string[], from: number, to: number): number[] { + const indices: number[] = []; + for (let i = from; i < to; i++) if (isDigit(chars[i]!)) indices.push(i); + return indices; +} + /** Last decimal separator within the affix-trimmed range, else the range end. */ function findPivot( chars: string[], diff --git a/packages/torph/src/lib/utils/lcs.ts b/packages/torph/src/lib/utils/lcs.ts new file mode 100644 index 0000000..be81418 --- /dev/null +++ b/packages/torph/src/lib/utils/lcs.ts @@ -0,0 +1,43 @@ +/** + * Longest common subsequence, reported as paired indices into `a` and `b`. + * + * Built over suffixes and walked *forwards* so ties resolve to the earliest + * match — a repeated word keeps its element on the first occurrence. Walking + * backwards resolves them the other way, and the text flies across the block. + */ +export function lcsIndices(a: string[], b: string[]): [number[], number[]] { + const m = a.length; + const n = b.length; + // dp[i][j] = length of the LCS of a[i..] and b[j..] + const dp: number[][] = Array.from({ length: m + 1 }, () => + Array(n + 1).fill(0), + ); + + for (let i = m - 1; i >= 0; i--) { + for (let j = n - 1; j >= 0; j--) { + dp[i]![j] = + a[i] === b[j] + ? dp[i + 1]![j + 1]! + 1 + : Math.max(dp[i + 1]![j]!, dp[i]![j + 1]!); + } + } + + const ai: number[] = []; + const bi: number[] = []; + let i = 0; + let j = 0; + while (i < m && j < n) { + if (a[i] === b[j]) { + ai.push(i); + bi.push(j); + i++; + j++; + } else if (dp[i + 1]![j]! >= dp[i]![j + 1]!) { + i++; + } else { + j++; + } + } + + return [ai, bi]; +} diff --git a/site/src/surfaces/playground/input-demo/index.tsx b/site/src/surfaces/playground/input-demo/index.tsx index 985b345..f9ad926 100644 --- a/site/src/surfaces/playground/input-demo/index.tsx +++ b/site/src/surfaces/playground/input-demo/index.tsx @@ -13,7 +13,9 @@ export const InputPlayground = () => { return (
- {query} + {query || 0} +
+ {`${query}`}