From dc8e0a0d980845350f2ad52298d7ba7f0182aa0b Mon Sep 17 00:00:00 2001 From: Nikita Guryev Date: Tue, 27 Oct 2026 14:12:06 +0300 Subject: [PATCH 1/4] fix(ClampedText, ClampedList): add smooth expand and collapse animation (DS-5644) Co-Authored-By: Claude Sonnet 5.5 --- .../ClampedList/ClampedList.stories.tsx | 2 +- .../components/ClampedList/ClampedList.tsx | 11 ++- .../src/components/ClampedList/types.ts | 5 ++ .../ClampedText/ClampedText.stories.tsx | 2 +- .../components/ClampedText/ClampedText.tsx | 8 +- .../src/components/ClampedText/types.ts | 5 ++ .../src/hooks/useCollapseAnimation/index.ts | 1 + .../useCollapseAnimation.test.tsx | 90 +++++++++++++++++++ .../useCollapseAnimation.ts | 64 +++++++++++++ .../components/ClampedList.api.md | 1 + .../components/ClampedText.api.md | 1 + 11 files changed, 185 insertions(+), 5 deletions(-) create mode 100644 packages/components/src/hooks/useCollapseAnimation/index.ts create mode 100644 packages/components/src/hooks/useCollapseAnimation/useCollapseAnimation.test.tsx create mode 100644 packages/components/src/hooks/useCollapseAnimation/useCollapseAnimation.ts diff --git a/packages/components/src/components/ClampedList/ClampedList.stories.tsx b/packages/components/src/components/ClampedList/ClampedList.stories.tsx index 5230fd49..6fbb0fdb 100644 --- a/packages/components/src/components/ClampedList/ClampedList.stories.tsx +++ b/packages/components/src/components/ClampedList/ClampedList.stories.tsx @@ -21,7 +21,7 @@ const meta = { parameters: { layout: 'padded', }, - tags: ['status:new', 'date:2026-09-07'], + tags: ['status:updated', 'date:2026-10-27'], } satisfies Meta; export default meta; diff --git a/packages/components/src/components/ClampedList/ClampedList.tsx b/packages/components/src/components/ClampedList/ClampedList.tsx index 44963fff..1cfa8daf 100644 --- a/packages/components/src/components/ClampedList/ClampedList.tsx +++ b/packages/components/src/components/ClampedList/ClampedList.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useMemo, type ReactNode } from 'react'; +import { useMemo, useRef, type ReactNode } from 'react'; import { once } from '@koobiq/logger'; import { @@ -11,6 +11,8 @@ import { } from '@koobiq/react-core'; import { IconChevronDown16, IconChevronUp16 } from '@koobiq/react-icons'; +import { useCollapseAnimation } from '../../hooks/useCollapseAnimation'; + import s from './ClampedList.module.css'; import { ClampedListTrigger } from './components'; import intlMessages from './intl'; @@ -33,6 +35,7 @@ export function ClampedList({ onExpandedChange, moreText, lessText, + duration, slotProps, }: ClampedListProps) { const allItems = useMemo(() => Array.from(items), [items]); @@ -90,6 +93,10 @@ export function ClampedList({ [allItems, isExpanded, normalizedCollapsedVisibleCount] ); + const contentRef = useRef(null); + + useCollapseAnimation(contentRef, preferredExpanded, duration); + const state = useMemo>( () => ({ visibleItems, hiddenItemCount, isExpanded }), [visibleItems, hiddenItemCount, isExpanded] @@ -98,7 +105,7 @@ export function ClampedList({ const onToggle = () => setPreferredExpanded((value) => !value); const contentProps = mergeProps( - { className: s.content }, + { className: s.content, ref: contentRef }, slotProps?.content, { id: contentId, role: 'group' } ); diff --git a/packages/components/src/components/ClampedList/types.ts b/packages/components/src/components/ClampedList/types.ts index 9de455cb..9a088e5a 100644 --- a/packages/components/src/components/ClampedList/types.ts +++ b/packages/components/src/components/ClampedList/types.ts @@ -41,6 +41,11 @@ export type ClampedListProps = { moreText?: ReactNode; /** Content displayed in the toggle when the list is expanded. */ lessText?: ReactNode; + /** + * Duration of the expand and collapse animation in milliseconds. + * @default 100 + */ + duration?: number; /** The props used for each slot inside. */ slotProps?: { content?: Omit, 'children'> & diff --git a/packages/components/src/components/ClampedText/ClampedText.stories.tsx b/packages/components/src/components/ClampedText/ClampedText.stories.tsx index 02af4ba0..38c91ab3 100644 --- a/packages/components/src/components/ClampedText/ClampedText.stories.tsx +++ b/packages/components/src/components/ClampedText/ClampedText.stories.tsx @@ -15,7 +15,7 @@ const meta = { parameters: { layout: 'centered', }, - tags: ['status:new', 'date:2026-09-07'], + tags: ['status:updated', 'date:2026-10-27'], } satisfies Meta; export default meta; diff --git a/packages/components/src/components/ClampedText/ClampedText.tsx b/packages/components/src/components/ClampedText/ClampedText.tsx index 8173f2a2..a5a86998 100644 --- a/packages/components/src/components/ClampedText/ClampedText.tsx +++ b/packages/components/src/components/ClampedText/ClampedText.tsx @@ -22,6 +22,8 @@ import { } from '@koobiq/react-core'; import { IconChevronDown16, IconChevronUp16 } from '@koobiq/react-icons'; +import { useCollapseAnimation } from '../../hooks/useCollapseAnimation'; + import s from './ClampedText.module.css'; import { ClampedTextTrigger } from './components'; import intlMessages from './intl.json'; @@ -46,6 +48,7 @@ export const ClampedText = forwardRef( onExpandedChange, moreText, lessText, + duration, slotProps, className, ...other @@ -83,7 +86,8 @@ export const ClampedText = forwardRef( useIsomorphicEffect(() => { const contentElement = contentRef.current; - if (!contentElement) return; + // The height changes on every frame of the collapse animation. + if (!contentElement || contentElement.getAnimations?.().length) return; const wasClamped = contentElement.classList.contains(s.clamped); @@ -117,6 +121,8 @@ export const ClampedText = forwardRef( ? !hasToggle || preferredExpanded : preferredExpanded; + useCollapseAnimation(contentRef, preferredExpanded, duration); + const isClamped = isMeasured ? hasToggle && !effectiveExpanded : !preferredExpanded; diff --git a/packages/components/src/components/ClampedText/types.ts b/packages/components/src/components/ClampedText/types.ts index d7bbf3c7..ab2c76d3 100644 --- a/packages/components/src/components/ClampedText/types.ts +++ b/packages/components/src/components/ClampedText/types.ts @@ -29,6 +29,11 @@ export type ClampedTextProps = ExtendableComponentPropsWithRef< moreText?: ReactNode; /** Content displayed in the toggle when the text is expanded. */ lessText?: ReactNode; + /** + * Duration of the expand and collapse animation in milliseconds. + * @default 100 + */ + duration?: number; /** The props used for each slot inside. */ slotProps?: { content?: Omit, 'children'> & diff --git a/packages/components/src/hooks/useCollapseAnimation/index.ts b/packages/components/src/hooks/useCollapseAnimation/index.ts new file mode 100644 index 00000000..3466b3ac --- /dev/null +++ b/packages/components/src/hooks/useCollapseAnimation/index.ts @@ -0,0 +1 @@ +export { useCollapseAnimation } from './useCollapseAnimation'; diff --git a/packages/components/src/hooks/useCollapseAnimation/useCollapseAnimation.test.tsx b/packages/components/src/hooks/useCollapseAnimation/useCollapseAnimation.test.tsx new file mode 100644 index 00000000..2ff24f1a --- /dev/null +++ b/packages/components/src/hooks/useCollapseAnimation/useCollapseAnimation.test.tsx @@ -0,0 +1,90 @@ +import { useRef } from 'react'; + +import { render } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { useCollapseAnimation } from './useCollapseAnimation'; + +const Test = ({ + isExpanded, + duration, +}: { + isExpanded: boolean; + duration?: number; +}) => { + const ref = useRef(null); + + useCollapseAnimation(ref, isExpanded, duration); + + return
; +}; + +describe('useCollapseAnimation', () => { + const animate = vi.fn(); + let heights: number[]; + + afterEach(() => { + animate.mockClear(); + Reflect.deleteProperty(HTMLElement.prototype, 'animate'); + vi.restoreAllMocks(); + }); + + const setup = (initialHeights: number[]) => { + heights = initialHeights; + + Object.defineProperty(HTMLElement.prototype, 'animate', { + configurable: true, + value: animate, + }); + + vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation( + () => ({ height: heights.shift() ?? 0 }) as DOMRect + ); + }; + + it('animates the block size between the heights before and after', () => { + setup([60, 120, 120]); + + const { rerender } = render(); + + rerender(); + + expect(animate).toHaveBeenCalledWith( + { blockSize: ['60px', '120px'], overflow: ['clip', 'clip'] }, + { duration: 100, easing: 'ease-in-out' } + ); + }); + + it('accepts a custom duration', () => { + setup([60, 120, 120]); + + const { rerender } = render(); + + rerender(); + + expect(animate).toHaveBeenCalledWith(expect.anything(), { + duration: 250, + easing: 'ease-in-out', + }); + }); + + it('does not animate on the first render or when the height is the same', () => { + setup([60, 60, 60]); + + const { rerender } = render(); + + rerender(); + + expect(animate).not.toHaveBeenCalled(); + }); + + it('does not animate when the expanded state is unchanged', () => { + setup([60, 120]); + + const { rerender } = render(); + + rerender(); + + expect(animate).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/components/src/hooks/useCollapseAnimation/useCollapseAnimation.ts b/packages/components/src/hooks/useCollapseAnimation/useCollapseAnimation.ts new file mode 100644 index 00000000..3418791d --- /dev/null +++ b/packages/components/src/hooks/useCollapseAnimation/useCollapseAnimation.ts @@ -0,0 +1,64 @@ +'use client'; + +import { useRef, type RefObject } from 'react'; + +import { useIsomorphicEffect, useMediaQuery } from '@koobiq/react-core'; + +const DEFAULT_DURATION = 100; + +/** + * Smoothly animates the block size of an element when `isExpanded` changes. + * The new content is already rendered when the animation starts, so it only + * animates the box between the heights before and after the change. + */ +export function useCollapseAnimation( + ref: RefObject, + isExpanded: boolean, + duration = DEFAULT_DURATION +) { + const lastRef = useRef<{ isExpanded: boolean; height: number }>(undefined); + + const [prefersReducedMotion] = useMediaQuery([ + '(prefers-reduced-motion: reduce)', + ]); + + // Runs after every commit to keep the last natural height up to date. + useIsomorphicEffect(() => { + const element = ref.current; + + if (!element) return; + + const last = lastRef.current; + const animations = element.getAnimations?.() ?? []; + const currentHeight = element.getBoundingClientRect().height; + + if (!last || last.isExpanded === isExpanded) { + // The height measured during an animation is not the natural one. + if (!animations.length) { + lastRef.current = { isExpanded, height: currentHeight }; + } + + return; + } + + // Continue from the visible height if the previous animation was running. + const from = animations.length ? currentHeight : last.height; + + animations.forEach((animation) => animation.cancel()); + + const height = element.getBoundingClientRect().height; + + lastRef.current = { isExpanded, height }; + + if (prefersReducedMotion || !element.animate || from === height) return; + + element.animate( + { + blockSize: [`${from}px`, `${height}px`], + // Hides the content that does not fit yet, without a scroll container. + overflow: ['clip', 'clip'], + }, + { duration, easing: 'ease-in-out' } + ); + }); +} diff --git a/tools/public_api_guard/components/ClampedList.api.md b/tools/public_api_guard/components/ClampedList.api.md index 1e2e9a6f..af9ca927 100644 --- a/tools/public_api_guard/components/ClampedList.api.md +++ b/tools/public_api_guard/components/ClampedList.api.md @@ -24,6 +24,7 @@ export type ClampedListProps = { onExpandedChange?: (isExpanded: boolean) => void; moreText?: ReactNode; lessText?: ReactNode; + duration?: number; slotProps?: { content?: Omit, 'children'> & DataAttributeProps; toggle?: Omit & { diff --git a/tools/public_api_guard/components/ClampedText.api.md b/tools/public_api_guard/components/ClampedText.api.md index 6cc2095d..62f8666a 100644 --- a/tools/public_api_guard/components/ClampedText.api.md +++ b/tools/public_api_guard/components/ClampedText.api.md @@ -25,6 +25,7 @@ export type ClampedTextProps = ExtendableComponentPropsWithRef<{ onExpandedChange?: (isExpanded: boolean) => void; moreText?: ReactNode; lessText?: ReactNode; + duration?: number; slotProps?: { content?: Omit, 'children'> & DataAttributeProps; toggle?: Omit & { From 4908740c4a6ef8fc3d5aab4be8339bf7af4af7ef Mon Sep 17 00:00:00 2001 From: Nikita Guryev Date: Tue, 27 Oct 2026 18:42:18 +0300 Subject: [PATCH 2/4] fix(ClampedText, ClampedList): validate animation duration and cancel only own animation (DS-5644) Co-Authored-By: Claude Sonnet 5.5 --- .../useCollapseAnimation.test.tsx | 18 +++++++++++++++ .../useCollapseAnimation.ts | 22 ++++++++++++++----- 2 files changed, 34 insertions(+), 6 deletions(-) diff --git a/packages/components/src/hooks/useCollapseAnimation/useCollapseAnimation.test.tsx b/packages/components/src/hooks/useCollapseAnimation/useCollapseAnimation.test.tsx index 2ff24f1a..2032300a 100644 --- a/packages/components/src/hooks/useCollapseAnimation/useCollapseAnimation.test.tsx +++ b/packages/components/src/hooks/useCollapseAnimation/useCollapseAnimation.test.tsx @@ -68,6 +68,24 @@ describe('useCollapseAnimation', () => { }); }); + it.each([-1, Number.NaN, Number.POSITIVE_INFINITY])( + 'falls back to the default duration for %s', + (duration) => { + setup([60, 120, 120]); + + const { rerender } = render( + + ); + + rerender(); + + expect(animate).toHaveBeenCalledWith(expect.anything(), { + duration: 100, + easing: 'ease-in-out', + }); + } + ); + it('does not animate on the first render or when the height is the same', () => { setup([60, 60, 60]); diff --git a/packages/components/src/hooks/useCollapseAnimation/useCollapseAnimation.ts b/packages/components/src/hooks/useCollapseAnimation/useCollapseAnimation.ts index 3418791d..ce83103f 100644 --- a/packages/components/src/hooks/useCollapseAnimation/useCollapseAnimation.ts +++ b/packages/components/src/hooks/useCollapseAnimation/useCollapseAnimation.ts @@ -17,6 +17,7 @@ export function useCollapseAnimation( duration = DEFAULT_DURATION ) { const lastRef = useRef<{ isExpanded: boolean; height: number }>(undefined); + const animationRef = useRef(undefined); const [prefersReducedMotion] = useMediaQuery([ '(prefers-reduced-motion: reduce)', @@ -29,12 +30,14 @@ export function useCollapseAnimation( if (!element) return; const last = lastRef.current; - const animations = element.getAnimations?.() ?? []; + // Only the own animation matters: the consumer may animate the element too. + const animation = animationRef.current; + const isAnimating = animation?.playState === 'running'; const currentHeight = element.getBoundingClientRect().height; if (!last || last.isExpanded === isExpanded) { // The height measured during an animation is not the natural one. - if (!animations.length) { + if (!isAnimating) { lastRef.current = { isExpanded, height: currentHeight }; } @@ -42,9 +45,9 @@ export function useCollapseAnimation( } // Continue from the visible height if the previous animation was running. - const from = animations.length ? currentHeight : last.height; + const from = isAnimating ? currentHeight : last.height; - animations.forEach((animation) => animation.cancel()); + animation?.cancel(); const height = element.getBoundingClientRect().height; @@ -52,13 +55,20 @@ export function useCollapseAnimation( if (prefersReducedMotion || !element.animate || from === height) return; - element.animate( + animationRef.current = element.animate( { blockSize: [`${from}px`, `${height}px`], // Hides the content that does not fit yet, without a scroll container. overflow: ['clip', 'clip'], }, - { duration, easing: 'ease-in-out' } + { + // The Web Animations API throws on negative and non-finite durations. + duration: + Number.isFinite(duration) && duration >= 0 + ? duration + : DEFAULT_DURATION, + easing: 'ease-in-out', + } ); }); } From e5683341c726af63bb3f99758f605617a6b70ad9 Mon Sep 17 00:00:00 2001 From: Nikita Guryev Date: Wed, 28 Oct 2026 11:08:18 +0300 Subject: [PATCH 3/4] fix(ClampedText): keep collapsing lines visible while the height animates (DS-5644) Co-Authored-By: Claude Sonnet 5.5 --- .../components/ClampedText/ClampedText.tsx | 29 ++++++++++++++++++- .../useCollapseAnimation.ts | 3 ++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/packages/components/src/components/ClampedText/ClampedText.tsx b/packages/components/src/components/ClampedText/ClampedText.tsx index a5a86998..d15c1199 100644 --- a/packages/components/src/components/ClampedText/ClampedText.tsx +++ b/packages/components/src/components/ClampedText/ClampedText.tsx @@ -121,12 +121,39 @@ export const ClampedText = forwardRef( ? !hasToggle || preferredExpanded : preferredExpanded; - useCollapseAnimation(contentRef, preferredExpanded, duration); + const animationRef = useCollapseAnimation( + contentRef, + preferredExpanded, + duration + ); const isClamped = isMeasured ? hasToggle && !effectiveExpanded : !preferredExpanded; + // Keeps the lines that are being collapsed visible until the animation + // is over, the clamp is applied back when it finishes. + useIsomorphicEffect(() => { + const contentElement = contentRef.current; + const animation = animationRef.current; + + if ( + preferredExpanded || + animation?.playState !== 'running' || + !contentElement?.classList.contains(s.clamped) + ) { + return; + } + + contentElement.classList.remove(s.clamped); + + // Rejects when the animation is canceled, e.g. by the next toggle. + animation.finished.then( + () => contentElement.classList.add(s.clamped), + () => {} + ); + }, [preferredExpanded]); + useEffect(() => { return () => window.clearTimeout(scrollTimeoutRef.current); }, []); diff --git a/packages/components/src/hooks/useCollapseAnimation/useCollapseAnimation.ts b/packages/components/src/hooks/useCollapseAnimation/useCollapseAnimation.ts index ce83103f..c2fbbc81 100644 --- a/packages/components/src/hooks/useCollapseAnimation/useCollapseAnimation.ts +++ b/packages/components/src/hooks/useCollapseAnimation/useCollapseAnimation.ts @@ -10,6 +10,7 @@ const DEFAULT_DURATION = 100; * Smoothly animates the block size of an element when `isExpanded` changes. * The new content is already rendered when the animation starts, so it only * animates the box between the heights before and after the change. + * Returns a ref to the animation that is currently set up by the hook. */ export function useCollapseAnimation( ref: RefObject, @@ -71,4 +72,6 @@ export function useCollapseAnimation( } ); }); + + return animationRef; } From 3e4d316125a7288004ff231a683836f972898f10 Mon Sep 17 00:00:00 2001 From: Nikita Guryev Date: Wed, 28 Oct 2026 11:24:10 +0300 Subject: [PATCH 4/4] fix(ClampedText): do not re-apply the clamp when it became unnecessary while collapsing (DS-5644) Co-Authored-By: Claude Sonnet 5.5 --- .../src/components/ClampedText/ClampedText.tsx | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/packages/components/src/components/ClampedText/ClampedText.tsx b/packages/components/src/components/ClampedText/ClampedText.tsx index d15c1199..86bb9703 100644 --- a/packages/components/src/components/ClampedText/ClampedText.tsx +++ b/packages/components/src/components/ClampedText/ClampedText.tsx @@ -138,6 +138,7 @@ export const ClampedText = forwardRef( const animation = animationRef.current; if ( + !isClamped || preferredExpanded || animation?.playState !== 'running' || !contentElement?.classList.contains(s.clamped) @@ -147,12 +148,21 @@ export const ClampedText = forwardRef( contentElement.classList.remove(s.clamped); + let isCurrent = true; + // Rejects when the animation is canceled, e.g. by the next toggle. animation.finished.then( - () => contentElement.classList.add(s.clamped), + () => { + if (isCurrent) contentElement.classList.add(s.clamped); + }, () => {} ); - }, [preferredExpanded]); + + // The clamp may become unnecessary while collapsing, e.g. after `rows` change. + return () => { + isCurrent = false; + }; + }, [isClamped, preferredExpanded]); useEffect(() => { return () => window.clearTimeout(scrollTimeoutRef.current);