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..86bb9703 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,10 +121,49 @@ export const ClampedText = forwardRef( ? !hasToggle || preferredExpanded : preferredExpanded; + 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 ( + !isClamped || + preferredExpanded || + animation?.playState !== 'running' || + !contentElement?.classList.contains(s.clamped) + ) { + return; + } + + contentElement.classList.remove(s.clamped); + + let isCurrent = true; + + // Rejects when the animation is canceled, e.g. by the next toggle. + animation.finished.then( + () => { + if (isCurrent) contentElement.classList.add(s.clamped); + }, + () => {} + ); + + // The clamp may become unnecessary while collapsing, e.g. after `rows` change. + return () => { + isCurrent = false; + }; + }, [isClamped, preferredExpanded]); + useEffect(() => { return () => window.clearTimeout(scrollTimeoutRef.current); }, []); 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..2032300a --- /dev/null +++ b/packages/components/src/hooks/useCollapseAnimation/useCollapseAnimation.test.tsx @@ -0,0 +1,108 @@ +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.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]); + + 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..c2fbbc81 --- /dev/null +++ b/packages/components/src/hooks/useCollapseAnimation/useCollapseAnimation.ts @@ -0,0 +1,77 @@ +'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. + * Returns a ref to the animation that is currently set up by the hook. + */ +export function useCollapseAnimation( + ref: RefObject, + isExpanded: boolean, + duration = DEFAULT_DURATION +) { + const lastRef = useRef<{ isExpanded: boolean; height: number }>(undefined); + const animationRef = useRef(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; + // 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 (!isAnimating) { + lastRef.current = { isExpanded, height: currentHeight }; + } + + return; + } + + // Continue from the visible height if the previous animation was running. + const from = isAnimating ? currentHeight : last.height; + + animation?.cancel(); + + const height = element.getBoundingClientRect().height; + + lastRef.current = { isExpanded, height }; + + if (prefersReducedMotion || !element.animate || from === height) return; + + animationRef.current = element.animate( + { + blockSize: [`${from}px`, `${height}px`], + // Hides the content that does not fit yet, without a scroll container. + overflow: ['clip', 'clip'], + }, + { + // The Web Animations API throws on negative and non-finite durations. + duration: + Number.isFinite(duration) && duration >= 0 + ? duration + : DEFAULT_DURATION, + easing: 'ease-in-out', + } + ); + }); + + return animationRef; +} 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 & {