Skip to content
Draft
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
Original file line number Diff line number Diff line change
Expand Up @@ -51,10 +51,11 @@ export type CounterBadgeProps = Omit<BadgeProps, 'appearance' | 'color' | 'shape
overflowCount?: number;
shape?: 'circular' | 'rounded';
showZero?: boolean;
isAnimated?: boolean;
};

// @public (undocumented)
export type CounterBadgeState = Omit<BadgeState, 'appearance' | 'color' | 'shape'> & Required<Pick<CounterBadgeProps, 'appearance' | 'color' | 'count' | 'dot' | 'shape' | 'showZero'>>;
export type CounterBadgeState = Omit<BadgeState, 'appearance' | 'color' | 'shape'> & Required<Pick<CounterBadgeProps, 'appearance' | 'color' | 'count' | 'dot' | 'shape' | 'showZero' | 'isAnimated'>>;

// @public (undocumented)
export const presenceAvailableFilled: Record<PresenceBadgeState['size'], React_2.FunctionComponent>;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
import * as React from 'react';
Copy link

@github-actions github-actions bot Dec 1, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🕵🏾‍♀️ visual changes to review in the Visual Change Report

vr-tests-react-components/Avatar Converged 1 screenshots
Image Name Diff(in Pixels) Image Type
vr-tests-react-components/Avatar Converged.badgeMask - RTL.normal.chromium.png 5 Changed
vr-tests-react-components/Charts-DonutChart 1 screenshots
Image Name Diff(in Pixels) Image Type
vr-tests-react-components/Charts-DonutChart.Dynamic - Dark Mode.default.chromium.png 12638 Changed
vr-tests-react-components/Drawer 1 screenshots
Image Name Diff(in Pixels) Image Type
vr-tests-react-components/Drawer.overlay drawer full - Dark Mode.chromium.png 8703 Changed
vr-tests-react-components/Positioning 2 screenshots
Image Name Diff(in Pixels) Image Type
vr-tests-react-components/Positioning.Positioning end.updated 2 times.chromium.png 74 Changed
vr-tests-react-components/Positioning.Positioning end.chromium.png 861 Changed

import { useEffect, useState, useRef } from 'react';

interface DigitSlotProps {
currentDigit: string;
previousDigit: string | null;
isAnimating: boolean;
direction: 'up' | 'down';
}

function DigitSlot({ currentDigit, previousDigit, isAnimating, direction }: DigitSlotProps) {
const [showPrevious, setShowPrevious] = useState(false);
const [animateOut, setAnimateOut] = useState(false);
const [animateIn, setAnimateIn] = useState(false);

useEffect(() => {
if (isAnimating && previousDigit !== null) {
setShowPrevious(true);
setAnimateOut(false);
setAnimateIn(false);

requestAnimationFrame(() => {
requestAnimationFrame(() => {
setAnimateOut(true);

setTimeout(() => {
setAnimateIn(true);

setTimeout(() => {
setShowPrevious(false);
setAnimateOut(false);
setAnimateIn(false);
}, 225);
}, 75);
});
});
}
}, [isAnimating, previousDigit]);

const baseStyle: React.CSSProperties = {
position: 'relative',
display: 'inline-block',
overflow: 'hidden',
};

const previousStyle: React.CSSProperties = {
position: 'absolute',
inset: '0',
transform: animateOut ? (direction === 'up' ? 'translateY(-100%)' : 'translateY(100%)') : 'translateY(0)',
opacity: animateOut ? 0 : 1,
transition: 'transform 75ms cubic-bezier(0.9,0.1,1,0.2), opacity 75ms cubic-bezier(0.9,0.1,1,0.2)',
};

const currentStyle: React.CSSProperties = {
display: 'inline-block',
transform: animateIn
? 'translateY(0)'
: isAnimating
? direction === 'up'
? 'translateY(100%)'
: 'translateY(-100%)'
: 'translateY(0)',
opacity: animateIn || !isAnimating ? 1 : 0,
transition: animateIn
? 'transform 150ms cubic-bezier(0.1,0.9,0.2,1), opacity 150ms cubic-bezier(0.1,0.9,0.2,1)'
: 'none',
};

return (
<span style={baseStyle}>
{showPrevious && previousDigit !== null && <span style={previousStyle}>{previousDigit}</span>}
<span style={currentStyle}>{currentDigit}</span>
</span>
);
}

interface AnimatedNumberProps {
value: number;
}

export function AnimatedNumber({ value }: AnimatedNumberProps) {
const [currentValue, setCurrentValue] = useState(value.toString());
const [previousValue, setPreviousValue] = useState<string | null>(null);
const [animatingIndices, setAnimatingIndices] = useState<Set<number>>(new Set());
const [direction, setDirection] = useState<'up' | 'down'>('up');
const prevValueRef = useRef(value);

useEffect(() => {
if (value === prevValueRef.current) return;

const prevStr = prevValueRef.current.toString();
const nextStr = value.toString();
const newDirection = value > prevValueRef.current ? 'up' : 'down';
setDirection(newDirection);

const maxLength = Math.max(prevStr.length, nextStr.length);
const paddedPrev = prevStr.padStart(maxLength, ' ');
const paddedNext = nextStr.padStart(maxLength, ' ');

const indicesToAnimate = new Set<number>();
for (let i = 0; i < maxLength; i++) {
if (paddedPrev[i] !== paddedNext[i]) {
indicesToAnimate.add(i);
}
}

setPreviousValue(currentValue);
setCurrentValue(nextStr);
setAnimatingIndices(indicesToAnimate);

setTimeout(() => {
setAnimatingIndices(new Set());
setPreviousValue(null);
}, 300);

prevValueRef.current = value;
}, [value, currentValue]);

const currentChars = currentValue.split('');
const previousChars = previousValue?.split('') || [];

const maxLength = Math.max(currentChars.length, previousChars.length);
const displayLength = currentChars.length;

return (
<span style={{ display: 'inline-flex' }}>
{currentChars.map((char, index) => {
const adjustedIndex = maxLength - displayLength + index;
const prevChar = previousChars[adjustedIndex] !== ' ' ? previousChars[adjustedIndex] : null;

return (
<DigitSlot
key={index}
currentDigit={char}
previousDigit={prevChar}
isAnimating={animatingIndices.has(adjustedIndex)}
direction={direction}
/>
);
})}
</span>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,13 @@ export type CounterBadgeProps = Omit<BadgeProps, 'appearance' | 'color' | 'shape
* @default false
*/
showZero?: boolean;

/**
* If the counter should animate when the count changes
* @default false
*/
isAnimated?: boolean;
};

export type CounterBadgeState = Omit<BadgeState, 'appearance' | 'color' | 'shape'> &
Required<Pick<CounterBadgeProps, 'appearance' | 'color' | 'count' | 'dot' | 'shape' | 'showZero'>>;
Required<Pick<CounterBadgeProps, 'appearance' | 'color' | 'count' | 'dot' | 'shape' | 'showZero' | 'isAnimated'>>;
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import * as React from 'react';
import type { BadgeState } from '../Badge/index';
import { useBadge_unstable } from '../Badge/index';
import type { CounterBadgeProps, CounterBadgeState } from './CounterBadge.types';
import { AnimatedNumber } from './AnimatedNumber';

/**
* Returns the props and state required to render the component
Expand All @@ -16,6 +17,7 @@ export const useCounterBadge_unstable = (props: CounterBadgeProps, ref: React.Re
overflowCount = 99,
count = 0,
dot = false,
isAnimated = false,
} = props;

const state: CounterBadgeState = {
Expand All @@ -25,10 +27,20 @@ export const useCounterBadge_unstable = (props: CounterBadgeProps, ref: React.Re
showZero,
count,
dot,
isAnimated,
};

if ((count !== 0 || showZero) && !dot && !state.root.children) {
state.root.children = count > overflowCount ? `${overflowCount}+` : `${count}`;
const displayValue = count > overflowCount ? overflowCount : count;
const displayString = count > overflowCount ? `${overflowCount}+` : `${count}`;

if (isAnimated && count <= overflowCount) {
// Use animated number for numeric values within overflow limit
state.root.children = <AnimatedNumber value={displayValue} />;
} else {
// Use static string for overflow or non-animated cases
state.root.children = displayString;
}
}

return state;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import * as React from 'react';
import type { JSXElement } from '@fluentui/react-components';

import { CounterBadge } from '@fluentui/react-components';
import type { CounterBadgeProps } from '@fluentui/react-components';

export const AnimatedBadge = (args: CounterBadgeProps): JSXElement => {
const [count, setCount] = React.useState(5);
const [inputValue, setInputValue] = React.useState('5');

const handleUpdate = () => {
const newCount = Number(inputValue);
if (!isNaN(newCount)) {
setCount(newCount);
}
};

const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
handleUpdate();
}
};

return (
<div>
<CounterBadge count={count} isAnimated={true} />
<div>
<button onClick={() => setCount(count + 1)}>Increase</button>
<button onClick={() => setCount(count - 1)}>Decrease</button>
<input
type="number"
value={inputValue}
onChange={e => setInputValue(e.target.value)}
onKeyDown={handleKeyDown}
style={{ marginLeft: '10px', padding: '5px' }}
/>
<button onClick={handleUpdate} style={{ marginLeft: '5px' }}>
Update
</button>
</div>
</div>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export { Shapes } from './CounterBadgeShapes.stories';
export { Sizes } from './CounterBadgeSizes.stories';
export { Color } from './CounterBadgeColor.stories';
export { Dot } from './CounterBadgeDot.stories';
export { AnimatedBadge } from './CounterBadgeAnimated.stories';

export default {
title: 'Components/Badge/Counter Badge',
Expand Down
Loading