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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions resources/css/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -272,6 +272,42 @@
both;
--animate-star-pop: star-pop 450ms cubic-bezier(0.175, 0.885, 0.32, 1.275)
both;
--animate-odometer-up-out: odometer-up-out 250ms
cubic-bezier(0.16, 1, 0.3, 1) both;
--animate-odometer-up-in: odometer-up-in 250ms cubic-bezier(0.16, 1, 0.3, 1)
both;
--animate-odometer-down-out: odometer-down-out 250ms
cubic-bezier(0.16, 1, 0.3, 1) both;
--animate-odometer-down-in: odometer-down-in 250ms
cubic-bezier(0.16, 1, 0.3, 1) both;

@keyframes odometer-up-out {
to {
transform: translateY(-100%);
opacity: 0;
}
}

@keyframes odometer-up-in {
from {
transform: translateY(100%);
opacity: 0;
}
}

@keyframes odometer-down-out {
to {
transform: translateY(100%);
opacity: 0;
}
}

@keyframes odometer-down-in {
from {
transform: translateY(-100%);
opacity: 0;
}
}
--animate-flame-flicker: flame-flicker 5s ease-in-out infinite;

@keyframes flame-flicker {
Expand Down
23 changes: 5 additions & 18 deletions resources/js/components/commit-star-button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,15 @@ import CommitStarController from '@/actions/App/Http/Controllers/CommitStarContr
import { ActionTooltip } from '@/components/action-tooltip';
import { CommitStarPollingContext } from '@/components/commit-star-polling';
import { CommitStargazersDialog } from '@/components/commit-stargazers-dialog';
import { OdometerCount } from '@/components/odometer-count';
import { StarredRemovalContext } from '@/components/starred-removal-context';
import { useCommitNotFound } from '@/hooks/use-deleted-resources';
import { useOwnsCommit } from '@/hooks/use-owns-commit';
import { refreshSharedState, useSharedState } from '@/hooks/use-shared-state';
import { signIn } from '@/lib/auth';
import { createCommitStarMutations } from '@/lib/commit-star-mutations';
import { commitStateKey } from '@/lib/commit-state';
import { formatCount, formatExactCount } from '@/lib/numbers';
import { formatExactCount } from '@/lib/numbers';
import { toastWhenThrottled } from '@/lib/throttle';
import { cn } from '@/lib/utils';
import type { Commit } from '@/types';
Expand Down Expand Up @@ -172,7 +173,7 @@ export function CommitStarButton({
aria-keyshortcuts={keyShortcut}
aria-label={starLabel(starred, count)}
className={cn(
'group/star inline-flex items-center gap-1 rounded-md px-1.5 py-1 font-mono text-[13px] text-muted-foreground transition-colors hover:bg-accent hover:text-foreground',
'group/star inline-flex items-center gap-1 rounded-md px-1.5 py-1 font-mono text-[13px] text-muted-foreground transition-colors hover:bg-accent hover:text-foreground has-[[data-test=star-count]:empty]:mr-[calc(0.25rem+1ch)]',
starred && 'text-primary hover:text-primary',
className,
)}
Expand All @@ -186,14 +187,7 @@ export function CommitStarButton({
isAnimating && 'motion-safe:animate-star-pop',
)}
/>
{count > 0 && (
<span
data-test="star-count"
className="min-w-[1ch] text-left tabular-nums"
>
{formatCount(count)}
</span>
)}
<OdometerCount value={count} emptyWhenZero />
</button>
</ActionTooltip>
);
Expand Down Expand Up @@ -235,14 +229,7 @@ function StarCount({
data-test="own-star-count"
>
<Star aria-hidden className="size-4" />
{count > 0 && (
<span
data-test="star-count"
className="min-w-[1ch] text-left tabular-nums"
>
{formatCount(count)}
</span>
)}
<OdometerCount value={count} emptyWhenZero />
</button>
</CommitStargazersDialog>
</ActionTooltip>
Expand Down
109 changes: 109 additions & 0 deletions resources/js/components/odometer-count.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import { useLayoutEffect, useRef, useState } from 'react';
import { formatCount } from '@/lib/numbers';
import { odometerPlaces } from '@/lib/odometer';
import { cn } from '@/lib/utils';

const ANIMATION_MS = 250;

interface OdometerCountProps {
value: number;
className?: string;
emptyWhenZero?: boolean;
}

function displayedCount(value: number, emptyWhenZero: boolean): string {
return emptyWhenZero && value === 0 ? '' : formatCount(value);
}

export function OdometerCount({
value,
className,
emptyWhenZero = false,
}: OdometerCountProps) {
const previousValueRef = useRef(value);
const [previous, setPrevious] = useState(() =>
displayedCount(value, emptyWhenZero),
);
const [isAnimating, setIsAnimating] = useState(false);
const [direction, setDirection] = useState<'up' | 'down'>('up');

useLayoutEffect(() => {
if (value === previousValueRef.current) {
return;
}

const oldValue = previousValueRef.current;
previousValueRef.current = value;
setPrevious(displayedCount(oldValue, emptyWhenZero));
setDirection(value >= oldValue ? 'up' : 'down');
setIsAnimating(true);

const timer = setTimeout(() => setIsAnimating(false), ANIMATION_MS);

return () => clearTimeout(timer);
}, [value, emptyWhenZero]);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved

const current = displayedCount(value, emptyWhenZero);
const places = odometerPlaces(previous, current);

return (
<span
data-test="star-count"
className={cn(
'inline-flex min-w-[1ch] items-center text-left tabular-nums empty:hidden',
className,
)}
>
{isAnimating
? places.map(({ char, previousChar, isRolling }, index) => {
const place = places.length - index;

if (!isRolling) {
return (
<span key={place} className="inline-block">
{char}
</span>
);
}

return (
<span
key={`${place}-${previousChar}-${char}-${direction}`}
className="relative inline-flex h-[1.25em] overflow-hidden leading-[1.25em]"
>
{previousChar !== ' ' && (
<span
aria-hidden="true"
className={cn(
'absolute inset-0 flex items-center justify-center motion-reduce:hidden',
direction === 'up'
? 'motion-safe:animate-odometer-up-out'
: 'motion-safe:animate-odometer-down-out',
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
)}
>
{previousChar}
</span>
)}
{char !== ' ' ? (
<span
className={cn(
'flex items-center justify-center',
direction === 'up'
? 'motion-safe:animate-odometer-up-in'
: 'motion-safe:animate-odometer-down-in',
)}
>
{char}
</span>
) : (
<span className="invisible inline-block">
{previousChar}
</span>
)}
</span>
);
})
: current}
</span>
);
}
48 changes: 48 additions & 0 deletions resources/js/lib/odometer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
export interface OdometerPlace {
/** The character shown at this place now. */
char: string;
/** The character it replaces, or a space when the count grows into this place. */
previousChar: string;
/** Whether this place rolls from the previous character. */
isRolling: boolean;
}

function isDigit(char: string): boolean {
return char >= '0' && char <= '9';
}

function rolls(char: string, previousChar: string): boolean {
if (char === previousChar) {
return false;
}

if (!isDigit(char) && !isDigit(previousChar)) {
return false;
}

if (isDigit(char) && isDigit(previousChar)) {
return true;
}

return isDigit(char) ? previousChar === ' ' : char === ' ';
}

/**
* The places of the new count, paired with the character each replaces, so the view rolls only what changed. A digit
* that appears or disappears rolls; a separator or a shortened count's suffix changes in place.
*/
export function odometerPlaces(
previous: string,
current: string,
): OdometerPlace[] {
const length = Math.max(previous.length, current.length);
const currentChars = current.padStart(length, ' ');
const previousChars = previous.padStart(length, ' ');

return Array.from({ length }, (_, index) => {
const char = currentChars[index];
const previousChar = previousChars[index];

return { char, previousChar, isRolling: rolls(char, previousChar) };
});
}
15 changes: 9 additions & 6 deletions tests/Unit/CommitStarButton.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ for (const status of ['approved', 'pending', 'rejected'] as const) {
assert.match(body, /disabled=""/);
assert.match(
body,
/data-test="own-star-count"[^>]*><svg[^>]*>.*?<\/svg><\/button>/,
/data-test="own-star-count"[^>]*><svg[^>]*>.*?<\/svg>.*?<\/button>/,
);
});

Expand All @@ -87,19 +87,22 @@ for (const user of [null, { id: 'viewer' }]) {
});
}

test('unstarred commits show only the star in the star control', async () => {
test('unstarred commits reserve the count slot outside the star control', async () => {
const body = await render({ id: 'viewer' }, { stars_count: 0 });

assert.match(
body,
/data-test="star-commit-button"[^>]*><svg[^>]*>.*?<\/svg><\/button>/,
assert.ok(
body.includes(
'has-[[data-test=star-count]:empty]:mr-[calc(0.25rem+1ch)]',
),
);
assert.ok(body.includes('empty:hidden'));
assert.match(body, /data-test="star-count"[^>]*><\/span>/);
});

test('starred commits keep the count slot inside the star control', async () => {
const body = await render({ id: 'viewer' }, { stars_count: 1 });

assert.doesNotMatch(body, /mr-\[calc/);
assert.doesNotMatch(body, / mr-\[calc/);
assert.match(body, /data-test="star-count"[^>]*>1<\/span>/);
});

Expand Down
50 changes: 50 additions & 0 deletions tests/Unit/Odometer.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { odometerPlaces } from '../../resources/js/lib/odometer.ts';

// A space is a place the count does not have; `·` makes it visible in the expectations.
const shown = (previous: string, current: string): string[] =>
odometerPlaces(previous, current).map(
({ char, previousChar, isRolling }) => {
const place = (value: string) => (value === ' ' ? '·' : value);

return isRolling
? `${place(previousChar)}→${place(char)}`
: place(char);
},
);

void test('leaves every place still when the count does not change', () => {
assert.deepEqual(shown('1,250', '1,250'), ['1', ',', '2', '5', '0']);
});

void test('rolls only the digit that changes', () => {
assert.deepEqual(shown('120', '121'), ['1', '2', '0→1']);
assert.deepEqual(shown('121', '120'), ['1', '2', '1→0']);
});

void test('rolls a new place in when the count grows', () => {
assert.deepEqual(shown('9', '10'), ['·→1', '9→0']);
assert.deepEqual(shown('99', '100'), ['·→1', '9→0', '9→0']);
});

void test('rolls the old place out when the count shrinks', () => {
assert.deepEqual(shown('10', '9'), ['1→·', '0→9']);
assert.deepEqual(shown('100', '99'), ['1→·', '0→9', '0→9']);
});

void test('leaves the thousands separator still while the digits roll', () => {
assert.deepEqual(shown('999', '1,000'), ['·→1', ',', '9→0', '9→0', '9→0']);
});

void test("leaves a shortened count's suffix still while the digits roll", () => {
// Covers both directions: the comma and `k` stay still; a digit replacing `k` also stays still.
assert.deepEqual(shown('9,999', '10k'), ['9→·', '·', '9→1', '9→0', 'k']);
assert.deepEqual(shown('10k', '9,999'), ['·→9', ',', '1→9', '0→9', '9']);
});

void test('rolls the first digit in and out when the count appears or disappears', () => {
assert.deepEqual(shown('', '1'), ['·→1']);
assert.deepEqual(shown('1', ''), ['1→·']);
assert.deepEqual(shown('', ''), []);
});