From 5d63ed336879dd291c5e89e5ac98312bdfd26349 Mon Sep 17 00:00:00 2001 From: Eddie Liu Date: Wed, 19 Nov 2025 13:55:03 -0800 Subject: [PATCH 1/4] Adding new story with increase/decrease badge count buttons --- .../CounterBadgeAnimated.stories.tsx | 19 +++++++++++++++++++ .../src/CounterBadge/index.stories.tsx | 1 + 2 files changed, 20 insertions(+) create mode 100644 packages/react-components/react-badge/stories/src/CounterBadge/CounterBadgeAnimated.stories.tsx diff --git a/packages/react-components/react-badge/stories/src/CounterBadge/CounterBadgeAnimated.stories.tsx b/packages/react-components/react-badge/stories/src/CounterBadge/CounterBadgeAnimated.stories.tsx new file mode 100644 index 00000000000000..97240ea5320186 --- /dev/null +++ b/packages/react-components/react-badge/stories/src/CounterBadge/CounterBadgeAnimated.stories.tsx @@ -0,0 +1,19 @@ +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); + + return ( +
+ +
+ + +
+
+ ); +}; diff --git a/packages/react-components/react-badge/stories/src/CounterBadge/index.stories.tsx b/packages/react-components/react-badge/stories/src/CounterBadge/index.stories.tsx index 4cf11484cb84a2..c97dde2e370377 100644 --- a/packages/react-components/react-badge/stories/src/CounterBadge/index.stories.tsx +++ b/packages/react-components/react-badge/stories/src/CounterBadge/index.stories.tsx @@ -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', From c4fabee1b0801bb2c4b814d862592cd97aad4c50 Mon Sep 17 00:00:00 2001 From: Eddie Liu Date: Wed, 19 Nov 2025 14:57:13 -0800 Subject: [PATCH 2/4] Animated badge work in progress --- .../CounterBadge/AnimatedNumber.tsx | 145 ++++++++++++++++++ .../CounterBadge/CounterBadge.types.ts | 8 +- ...useCounterBadge.ts => useCounterBadge.tsx} | 14 +- .../CounterBadgeAnimated.stories.tsx | 2 +- 4 files changed, 166 insertions(+), 3 deletions(-) create mode 100644 packages/react-components/react-badge/library/src/components/CounterBadge/AnimatedNumber.tsx rename packages/react-components/react-badge/library/src/components/CounterBadge/{useCounterBadge.ts => useCounterBadge.tsx} (61%) diff --git a/packages/react-components/react-badge/library/src/components/CounterBadge/AnimatedNumber.tsx b/packages/react-components/react-badge/library/src/components/CounterBadge/AnimatedNumber.tsx new file mode 100644 index 00000000000000..51fb8a660c7672 --- /dev/null +++ b/packages/react-components/react-badge/library/src/components/CounterBadge/AnimatedNumber.tsx @@ -0,0 +1,145 @@ +'use client'; + +import * as React from 'react'; +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 ( + + {showPrevious && previousDigit !== null && {previousDigit}} + {currentDigit} + + ); +} + +interface AnimatedNumberProps { + value: number; +} + +export function AnimatedNumber({ value }: AnimatedNumberProps) { + const [currentValue, setCurrentValue] = useState(value.toString()); + const [previousValue, setPreviousValue] = useState(null); + const [animatingIndices, setAnimatingIndices] = useState>(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(); + 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 ( + + {currentChars.map((char, index) => { + const adjustedIndex = maxLength - displayLength + index; + const prevChar = previousChars[adjustedIndex] !== ' ' ? previousChars[adjustedIndex] : null; + + return ( + + ); + })} + + ); +} diff --git a/packages/react-components/react-badge/library/src/components/CounterBadge/CounterBadge.types.ts b/packages/react-components/react-badge/library/src/components/CounterBadge/CounterBadge.types.ts index 03b0bf09670835..95873e35442188 100644 --- a/packages/react-components/react-badge/library/src/components/CounterBadge/CounterBadge.types.ts +++ b/packages/react-components/react-badge/library/src/components/CounterBadge/CounterBadge.types.ts @@ -45,7 +45,13 @@ export type CounterBadgeProps = Omit & - Required>; + Required>; diff --git a/packages/react-components/react-badge/library/src/components/CounterBadge/useCounterBadge.ts b/packages/react-components/react-badge/library/src/components/CounterBadge/useCounterBadge.tsx similarity index 61% rename from packages/react-components/react-badge/library/src/components/CounterBadge/useCounterBadge.ts rename to packages/react-components/react-badge/library/src/components/CounterBadge/useCounterBadge.tsx index a4132f5031134c..af0561cf3162e4 100644 --- a/packages/react-components/react-badge/library/src/components/CounterBadge/useCounterBadge.ts +++ b/packages/react-components/react-badge/library/src/components/CounterBadge/useCounterBadge.tsx @@ -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 @@ -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 = { @@ -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 = ; + } else { + // Use static string for overflow or non-animated cases + state.root.children = displayString; + } } return state; diff --git a/packages/react-components/react-badge/stories/src/CounterBadge/CounterBadgeAnimated.stories.tsx b/packages/react-components/react-badge/stories/src/CounterBadge/CounterBadgeAnimated.stories.tsx index 97240ea5320186..301a89a7cc8a56 100644 --- a/packages/react-components/react-badge/stories/src/CounterBadge/CounterBadgeAnimated.stories.tsx +++ b/packages/react-components/react-badge/stories/src/CounterBadge/CounterBadgeAnimated.stories.tsx @@ -9,7 +9,7 @@ export const AnimatedBadge = (args: CounterBadgeProps): JSXElement => { return (
- +
From c7affc38838a3057939d844b01f010330fa0a097 Mon Sep 17 00:00:00 2001 From: Eddie Liu Date: Wed, 26 Nov 2025 09:37:55 -0800 Subject: [PATCH 3/4] Update --- .../CounterBadge/AnimatedNumber.tsx | 2 -- .../CounterBadgeAnimated.stories.tsx | 24 +++++++++++++++++++ 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/packages/react-components/react-badge/library/src/components/CounterBadge/AnimatedNumber.tsx b/packages/react-components/react-badge/library/src/components/CounterBadge/AnimatedNumber.tsx index 51fb8a660c7672..0b4eb41cdcf171 100644 --- a/packages/react-components/react-badge/library/src/components/CounterBadge/AnimatedNumber.tsx +++ b/packages/react-components/react-badge/library/src/components/CounterBadge/AnimatedNumber.tsx @@ -1,5 +1,3 @@ -'use client'; - import * as React from 'react'; import { useEffect, useState, useRef } from 'react'; diff --git a/packages/react-components/react-badge/stories/src/CounterBadge/CounterBadgeAnimated.stories.tsx b/packages/react-components/react-badge/stories/src/CounterBadge/CounterBadgeAnimated.stories.tsx index 301a89a7cc8a56..433271c9c0744c 100644 --- a/packages/react-components/react-badge/stories/src/CounterBadge/CounterBadgeAnimated.stories.tsx +++ b/packages/react-components/react-badge/stories/src/CounterBadge/CounterBadgeAnimated.stories.tsx @@ -6,6 +6,20 @@ 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) => { + if (e.key === 'Enter') { + handleUpdate(); + } + }; return (
@@ -13,6 +27,16 @@ export const AnimatedBadge = (args: CounterBadgeProps): JSXElement => {
+ setInputValue(e.target.value)} + onKeyDown={handleKeyDown} + style={{ marginLeft: '10px', padding: '5px' }} + /> +
); From 5eb50347c3c94337632f9b3f93188c5505deda34 Mon Sep 17 00:00:00 2001 From: Eddie Liu Date: Mon, 1 Dec 2025 09:26:55 -0800 Subject: [PATCH 4/4] update api.md --- .../react-badge/library/etc/react-badge.api.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/react-components/react-badge/library/etc/react-badge.api.md b/packages/react-components/react-badge/library/etc/react-badge.api.md index cc6b4b8cc33216..3a930be24326de 100644 --- a/packages/react-components/react-badge/library/etc/react-badge.api.md +++ b/packages/react-components/react-badge/library/etc/react-badge.api.md @@ -51,10 +51,11 @@ export type CounterBadgeProps = Omit & Required>; +export type CounterBadgeState = Omit & Required>; // @public (undocumented) export const presenceAvailableFilled: Record;