mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
Animate reaction counts (#904)
This commit is contained in:
@@ -6,6 +6,7 @@ import type { TimelineReaction } from "@/features/messages/types";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { emojiDisplayName } from "@/shared/lib/emojiName";
|
||||
import { rewriteRelayUrl } from "@/shared/lib/mediaUrl";
|
||||
import { AnimatedCount } from "@/shared/ui/AnimatedCount";
|
||||
import {
|
||||
isPositiveEmojiParticle,
|
||||
useEmojiBurst,
|
||||
@@ -433,7 +434,10 @@ function ReactionPill({
|
||||
type="button"
|
||||
>
|
||||
<EmojiGlyph reaction={reaction} className={REACTION_GLYPH_CLASSES} />
|
||||
<span className="text-muted-foreground">{reaction.count}</span>
|
||||
<AnimatedCount
|
||||
className="text-muted-foreground"
|
||||
value={reaction.count}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -463,7 +467,10 @@ function ReactionPill({
|
||||
reaction={reaction}
|
||||
className={REACTION_GLYPH_CLASSES}
|
||||
/>
|
||||
<span className="text-muted-foreground">{reaction.count}</span>
|
||||
<AnimatedCount
|
||||
className="text-muted-foreground"
|
||||
value={reaction.count}
|
||||
/>
|
||||
</button>
|
||||
</span>
|
||||
</PopoverTrigger>
|
||||
|
||||
@@ -14,6 +14,7 @@ import { useNoteByIdQuery } from "@/features/pulse/hooks";
|
||||
import { getReplyParent, noteSnippet } from "@/features/pulse/lib/replies";
|
||||
import type { UserNote } from "@/shared/api/socialTypes";
|
||||
import type { ChannelMember, UserProfileSummary } from "@/shared/api/types";
|
||||
import { AnimatedCount } from "@/shared/ui/AnimatedCount";
|
||||
import { Markdown } from "@/shared/ui/markdown";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
|
||||
import { UserAvatar } from "@/shared/ui/UserAvatar";
|
||||
@@ -149,9 +150,7 @@ export function NoteCard({
|
||||
const activeActionClass = "text-primary";
|
||||
const countPlaceholder = <span aria-hidden className="w-2.5" />;
|
||||
const reactionCountLabel =
|
||||
reactionCount > 0 ? (
|
||||
<span className="tabular-nums">{reactionCount}</span>
|
||||
) : null;
|
||||
reactionCount > 0 ? <AnimatedCount value={reactionCount} /> : null;
|
||||
const currentUserAvatarUrl = currentUserProfile?.avatarUrl ?? null;
|
||||
const replyParentId = getReplyParent(note);
|
||||
|
||||
@@ -224,7 +223,7 @@ export function NoteCard({
|
||||
<button
|
||||
aria-label={isUpvoted ? "Unlike" : "Like"}
|
||||
aria-pressed={isUpvoted}
|
||||
className={`${actionButtonClass} ${isUpvoted ? activeActionClass : ""} disabled:cursor-not-allowed disabled:opacity-45`}
|
||||
className={`${actionButtonClass} ${isUpvoted ? activeActionClass : ""} disabled:opacity-45`}
|
||||
disabled={isUpvotePending}
|
||||
onClick={() => {
|
||||
if (!isUpvotePending) {
|
||||
|
||||
@@ -97,6 +97,129 @@
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
.sprout-animated-count {
|
||||
display: inline-block;
|
||||
font-kerning: none;
|
||||
font-variant-numeric: tabular-nums;
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.sprout-animated-count__motion {
|
||||
display: inline-flex;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.sprout-animated-count__static {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.sprout-animated-count__slot {
|
||||
display: inline-block;
|
||||
height: 1em;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.sprout-animated-count__slot--digit {
|
||||
overflow: hidden;
|
||||
text-align: center;
|
||||
width: 1ch;
|
||||
}
|
||||
|
||||
.sprout-animated-count__reel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 2em;
|
||||
line-height: 1;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.sprout-animated-count__reel > span {
|
||||
display: block;
|
||||
height: 1em;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.sprout-animated-count__slot[data-direction="up"] .sprout-animated-count__reel {
|
||||
animation: sprout-animated-count-roll-up 260ms cubic-bezier(0.2, 0.8, 0.2, 1)
|
||||
both;
|
||||
}
|
||||
|
||||
.sprout-animated-count__slot[data-direction="down"]
|
||||
.sprout-animated-count__reel {
|
||||
animation: sprout-animated-count-roll-down 260ms
|
||||
cubic-bezier(0.2, 0.8, 0.2, 1) both;
|
||||
}
|
||||
|
||||
.sprout-animated-count__symbol {
|
||||
display: inline-block;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.sprout-animated-count__symbol[data-direction="up"] {
|
||||
animation: sprout-animated-count-symbol-up 220ms
|
||||
cubic-bezier(0.2, 0.8, 0.2, 1) both;
|
||||
}
|
||||
|
||||
.sprout-animated-count__symbol[data-direction="down"] {
|
||||
animation: sprout-animated-count-symbol-down 220ms
|
||||
cubic-bezier(0.2, 0.8, 0.2, 1) both;
|
||||
}
|
||||
|
||||
@keyframes sprout-animated-count-roll-up {
|
||||
from {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: translateY(-1em);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes sprout-animated-count-roll-down {
|
||||
from {
|
||||
transform: translateY(-1em);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes sprout-animated-count-symbol-up {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(0.25em);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes sprout-animated-count-symbol-down {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-0.25em);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.sprout-animated-count__motion {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.sprout-animated-count__static {
|
||||
display: inline;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Tiptap rich-text composer ──────────────────────────────────────── */
|
||||
.rich-text-composer .tiptap {
|
||||
outline: none;
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import {
|
||||
formatAnimatedCount,
|
||||
getAnimatedCountSlots,
|
||||
normalizeAnimatedCount,
|
||||
} from "@/shared/ui/animatedCountParts";
|
||||
|
||||
type CountDirection = -1 | 0 | 1;
|
||||
|
||||
type AnimatedCountTransition = {
|
||||
current: string;
|
||||
direction: CountDirection;
|
||||
previous: string;
|
||||
version: number;
|
||||
};
|
||||
|
||||
type AnimatedCountProps = {
|
||||
className?: string;
|
||||
value: number;
|
||||
};
|
||||
|
||||
const ANIMATED_COUNT_SETTLE_DELAY_MS = 260;
|
||||
|
||||
function directionFor(previous: number, current: number): CountDirection {
|
||||
if (current > previous) return 1;
|
||||
if (current < previous) return -1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
function renderSlotText(value: string) {
|
||||
return value || "\u00A0";
|
||||
}
|
||||
|
||||
function AnimatedCountSlot({
|
||||
current,
|
||||
direction,
|
||||
isDigit,
|
||||
previous,
|
||||
version,
|
||||
}: {
|
||||
current: string;
|
||||
direction: CountDirection;
|
||||
isDigit: boolean;
|
||||
previous: string;
|
||||
version: number;
|
||||
}) {
|
||||
if (direction === 0 || previous === current) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"sprout-animated-count__slot",
|
||||
isDigit && "sprout-animated-count__slot--digit",
|
||||
)}
|
||||
>
|
||||
{renderSlotText(current)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isDigit) {
|
||||
return (
|
||||
<span
|
||||
className="sprout-animated-count__symbol"
|
||||
data-direction={direction > 0 ? "up" : "down"}
|
||||
key={`${version}-${previous}-${current}`}
|
||||
>
|
||||
{renderSlotText(current)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
className="sprout-animated-count__slot sprout-animated-count__slot--digit"
|
||||
data-direction={direction > 0 ? "up" : "down"}
|
||||
>
|
||||
<span
|
||||
className="sprout-animated-count__reel"
|
||||
key={`${version}-${previous}-${current}`}
|
||||
>
|
||||
{direction > 0 ? (
|
||||
<>
|
||||
<span>{renderSlotText(previous)}</span>
|
||||
<span>{renderSlotText(current)}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span>{renderSlotText(current)}</span>
|
||||
<span>{renderSlotText(previous)}</span>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function AnimatedCount({ className, value }: AnimatedCountProps) {
|
||||
const normalizedValue = normalizeAnimatedCount(value);
|
||||
const formattedValue = formatAnimatedCount(normalizedValue);
|
||||
const previousValueRef = React.useRef(normalizedValue);
|
||||
const [transition, setTransition] = React.useState<AnimatedCountTransition>(
|
||||
() => ({
|
||||
current: formattedValue,
|
||||
direction: 0,
|
||||
previous: formattedValue,
|
||||
version: 0,
|
||||
}),
|
||||
);
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
setTransition((currentTransition) => {
|
||||
if (currentTransition.current === formattedValue) {
|
||||
previousValueRef.current = normalizedValue;
|
||||
return currentTransition;
|
||||
}
|
||||
|
||||
const direction = directionFor(previousValueRef.current, normalizedValue);
|
||||
previousValueRef.current = normalizedValue;
|
||||
|
||||
return {
|
||||
current: formattedValue,
|
||||
direction,
|
||||
previous: currentTransition.current,
|
||||
version: currentTransition.version + 1,
|
||||
};
|
||||
});
|
||||
}, [formattedValue, normalizedValue]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (
|
||||
transition.direction === 0 ||
|
||||
transition.previous === transition.current
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
setTransition((currentTransition) => {
|
||||
if (currentTransition.version !== transition.version) {
|
||||
return currentTransition;
|
||||
}
|
||||
|
||||
return {
|
||||
...currentTransition,
|
||||
direction: 0,
|
||||
previous: currentTransition.current,
|
||||
};
|
||||
});
|
||||
}, ANIMATED_COUNT_SETTLE_DELAY_MS);
|
||||
|
||||
return () => window.clearTimeout(timeoutId);
|
||||
}, [
|
||||
transition.current,
|
||||
transition.direction,
|
||||
transition.previous,
|
||||
transition.version,
|
||||
]);
|
||||
|
||||
const slots = getAnimatedCountSlots(transition.previous, transition.current);
|
||||
|
||||
return (
|
||||
<span className={cn("sprout-animated-count", className)}>
|
||||
<span className="sr-only">{transition.current}</span>
|
||||
<span aria-hidden className="sprout-animated-count__motion">
|
||||
{slots.map((slot) => (
|
||||
<AnimatedCountSlot
|
||||
current={slot.current}
|
||||
direction={transition.direction}
|
||||
isDigit={slot.isDigit}
|
||||
key={`${transition.version}-${slot.place}`}
|
||||
previous={slot.previous}
|
||||
version={transition.version}
|
||||
/>
|
||||
))}
|
||||
</span>
|
||||
<span aria-hidden className="sprout-animated-count__static">
|
||||
{transition.current}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
formatAnimatedCount,
|
||||
getAnimatedCountSlots,
|
||||
normalizeAnimatedCount,
|
||||
} from "./animatedCountParts.ts";
|
||||
|
||||
test("normalizeAnimatedCount clamps to displayable whole counts", () => {
|
||||
assert.equal(normalizeAnimatedCount(4.9), 4);
|
||||
assert.equal(normalizeAnimatedCount(-1), 0);
|
||||
assert.equal(normalizeAnimatedCount(Number.NaN), 0);
|
||||
});
|
||||
|
||||
test("formatAnimatedCount uses stable grouped ASCII digits", () => {
|
||||
assert.equal(formatAnimatedCount(1234), "1,234");
|
||||
});
|
||||
|
||||
test("getAnimatedCountSlots right-aligns digits for rollover", () => {
|
||||
assert.deepEqual(getAnimatedCountSlots("9", "10"), [
|
||||
{ current: "1", isDigit: true, place: 1, previous: "" },
|
||||
{ current: "0", isDigit: true, place: 0, previous: "9" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("getAnimatedCountSlots shrinks to current width after settling", () => {
|
||||
assert.equal(getAnimatedCountSlots("1,000", "999").length, 5);
|
||||
assert.deepEqual(getAnimatedCountSlots("999", "999"), [
|
||||
{ current: "9", isDigit: true, place: 2, previous: "9" },
|
||||
{ current: "9", isDigit: true, place: 1, previous: "9" },
|
||||
{ current: "9", isDigit: true, place: 0, previous: "9" },
|
||||
]);
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
export type AnimatedCountSlot = {
|
||||
current: string;
|
||||
isDigit: boolean;
|
||||
place: number;
|
||||
previous: string;
|
||||
};
|
||||
|
||||
const COUNT_FORMATTER = new Intl.NumberFormat("en-US", {
|
||||
maximumFractionDigits: 0,
|
||||
});
|
||||
|
||||
export function normalizeAnimatedCount(value: number): number {
|
||||
if (!Number.isFinite(value)) return 0;
|
||||
return Math.max(0, Math.trunc(value));
|
||||
}
|
||||
|
||||
export function formatAnimatedCount(value: number): string {
|
||||
return COUNT_FORMATTER.format(normalizeAnimatedCount(value));
|
||||
}
|
||||
|
||||
export function isAsciiDigit(value: string): boolean {
|
||||
return value.length === 1 && value >= "0" && value <= "9";
|
||||
}
|
||||
|
||||
export function getAnimatedCountSlots(
|
||||
previous: string,
|
||||
current: string,
|
||||
): AnimatedCountSlot[] {
|
||||
const width = Math.max(previous.length, current.length);
|
||||
const previousOffset = width - previous.length;
|
||||
const currentOffset = width - current.length;
|
||||
|
||||
return Array.from({ length: width }, (_, index) => {
|
||||
const previousCharacter =
|
||||
index >= previousOffset ? previous[index - previousOffset] : "";
|
||||
const currentCharacter =
|
||||
index >= currentOffset ? current[index - currentOffset] : "";
|
||||
|
||||
return {
|
||||
current: currentCharacter ?? "",
|
||||
isDigit:
|
||||
isAsciiDigit(previousCharacter) || isAsciiDigit(currentCharacter),
|
||||
place: width - index - 1,
|
||||
previous: previousCharacter ?? "",
|
||||
};
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user