From 45b8df336f4adabeb3a655bda7637fd54669838e Mon Sep 17 00:00:00 2001 From: Nikita Guryev Date: Tue, 27 Oct 2026 18:31:02 +0300 Subject: [PATCH 1/5] feat(clamped-text): animate expanding and collapsing of clamped text and list (#DS-5644) Co-Authored-By: Claude Sonnet 5.5 --- .../clamped-text/clamped-list.spec.ts | 93 ++++++++++++++ .../components/clamped-text/clamped-list.ts | 9 +- .../clamped-text/clamped-text.spec.ts | 120 ++++++++++++++++++ .../components/clamped-text/clamped-text.ts | 11 +- .../clamped-text/collapse-animation.ts | 50 ++++++++ .../components/clamped-text.api.md | 6 +- 6 files changed, 285 insertions(+), 4 deletions(-) create mode 100644 packages/components/clamped-text/collapse-animation.ts diff --git a/packages/components/clamped-text/clamped-list.spec.ts b/packages/components/clamped-text/clamped-list.spec.ts index e1fb029f86..a8e5344bb8 100644 --- a/packages/components/clamped-text/clamped-list.spec.ts +++ b/packages/components/clamped-text/clamped-list.spec.ts @@ -7,6 +7,28 @@ import { KbqClampedList, KbqClampedListTrigger } from './clamped-list'; const AXE_TIMEOUT = 15000; +/** jsdom has no Web Animations API, so `animate` is supplied for the specs that observe the animation. */ +const mockAnimate = () => { + const animate = vi.fn(() => ({ cancel: vi.fn() }) as unknown as Animation); + + Object.defineProperty(HTMLElement.prototype, 'animate', { value: animate, configurable: true, writable: true }); + + return animate; +}; + +const mockReducedMotion = () => { + Object.defineProperty(window, 'matchMedia', { + value: () => ({ matches: true }), + configurable: true, + writable: true + }); +}; + +const restoreAnimationEnvironment = () => { + delete (HTMLElement.prototype as { animate?: unknown }).animate; + delete (window as { matchMedia?: unknown }).matchMedia; +}; + function getItems(debugElement: DebugElement) { return debugElement.queryAll(By.css('.item')); } @@ -31,6 +53,7 @@ function getHost(debugElement: DebugElement): HTMLElement { [items]="items()" [collapsedVisibleCount]="collapsedVisibleCount()" [hiddenThreshold]="hiddenThreshold()" + [duration]="duration()" > @for (item of clampedList.visibleItems(); track item) { {{ item }} @@ -52,6 +75,7 @@ class TestHostComponent { readonly items = signal(Array.from({ length: 17 }, (_, i) => `Item ${i + 1}`)); readonly collapsedVisibleCount = signal(10); readonly hiddenThreshold = signal(6); + readonly duration = signal(100); } describe('KbqClampedList', () => { @@ -184,4 +208,73 @@ describe('KbqClampedList', () => { AXE_TIMEOUT ); }); + + describe('animation', () => { + const ITEM_HEIGHT = 10; + + beforeEach(() => { + vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) { + return { height: this.querySelectorAll('.item').length * ITEM_HEIGHT } as DOMRect; + }); + }); + + afterEach(restoreAnimationEnvironment); + + it('should animate block-size of the host between the heights before and after the toggle', async () => { + const animate = mockAnimate(); + + getTrigger(debugElement).nativeElement.click(); + await fixture.whenStable(); + + expect(animate).toHaveBeenCalledTimes(1); + expect(animate).toHaveBeenCalledWith( + { blockSize: ['100px', '170px'], overflow: ['clip', 'clip'] }, + { duration: 100, easing: 'ease-in-out' } + ); + }); + + it('should respect the duration', async () => { + const animate = mockAnimate(); + + fixture.componentInstance.duration.set(250); + fixture.detectChanges(); + + getTrigger(debugElement).nativeElement.click(); + await fixture.whenStable(); + + expect(animate).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ duration: 250 })); + }); + + it('should not animate when the height does not change', async () => { + const animate = mockAnimate(); + + vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockReturnValue({ height: 100 } as DOMRect); + + getTrigger(debugElement).nativeElement.click(); + await fixture.whenStable(); + + expect(animate).not.toHaveBeenCalled(); + }); + + it('should not animate when the user prefers reduced motion', async () => { + const animate = mockAnimate(); + + mockReducedMotion(); + + getTrigger(debugElement).nativeElement.click(); + await fixture.whenStable(); + + expect(animate).not.toHaveBeenCalled(); + expect(getItems(debugElement).length).toBe(fixture.componentInstance.items().length); + }); + + it('should not animate when the items change without a toggle', async () => { + const animate = mockAnimate(); + + fixture.componentInstance.items.set(Array.from({ length: 30 }, (_, i) => `Item ${i + 1}`)); + await fixture.whenStable(); + + expect(animate).not.toHaveBeenCalled(); + }); + }); }); diff --git a/packages/components/clamped-text/clamped-list.ts b/packages/components/clamped-text/clamped-list.ts index 4620761cfd..15641bb890 100644 --- a/packages/components/clamped-text/clamped-list.ts +++ b/packages/components/clamped-text/clamped-list.ts @@ -1,6 +1,7 @@ import { _IdGenerator } from '@angular/cdk/a11y'; import { computed, Directive, inject, input, model, numberAttribute } from '@angular/core'; import { kbqInjectNativeElement, KbqLocaleOverridesDirective } from '@koobiq/components/core'; +import { kbqInjectCollapseAnimation } from './collapse-animation'; import { KbqClamped, KbqClampedRoot, kbqInjectClampedTextLocaleConfiguration } from './constants'; /** @@ -23,6 +24,7 @@ import { KbqClamped, KbqClampedRoot, kbqInjectClampedTextLocaleConfiguration } f }) export class KbqClampedList implements KbqClamped { private readonly nativeElement = kbqInjectNativeElement(); + private readonly animateCollapse = kbqInjectCollapseAnimation(); /** Collapsed state: `true` = collapsed, `false` = expanded, `undefined` = expanded. */ readonly isCollapsed = model(true); @@ -39,6 +41,11 @@ export class KbqClampedList implements KbqClamped { */ readonly hiddenThreshold = input(6, { transform: numberAttribute }); + /** + * Duration of the expand and collapse animation in milliseconds. Defaults to `100`. + */ + readonly duration = input(100, { transform: numberAttribute }); + /** Id the trigger points its `aria-controls` at. Keeps an id the host already carries. */ readonly contentId: string = this.nativeElement.id || inject(_IdGenerator).getId('kbq-clamped-list-'); @@ -61,7 +68,7 @@ export class KbqClampedList implements KbqClamped { /** Toggles the collapsed state of the list. Stops event propagation. */ toggle(event: Event) { event.stopPropagation(); - this.isCollapsed.update((state) => !state); + this.animateCollapse(this.nativeElement, this.duration(), () => this.isCollapsed.update((state) => !state)); } } diff --git a/packages/components/clamped-text/clamped-text.spec.ts b/packages/components/clamped-text/clamped-text.spec.ts index c347a5d378..cf1eb7669d 100644 --- a/packages/components/clamped-text/clamped-text.spec.ts +++ b/packages/components/clamped-text/clamped-text.spec.ts @@ -69,6 +69,28 @@ const createMeasuredComponent = async (component: Type): Promise { + const animate = vi.fn(() => ({ cancel: vi.fn() }) as unknown as Animation); + + Object.defineProperty(HTMLElement.prototype, 'animate', { value: animate, configurable: true, writable: true }); + + return animate; +}; + +const mockReducedMotion = () => { + Object.defineProperty(window, 'matchMedia', { + value: () => ({ matches: true }), + configurable: true, + writable: true + }); +}; + +const restoreAnimationEnvironment = () => { + delete (HTMLElement.prototype as { animate?: unknown }).animate; + delete (window as { matchMedia?: unknown }).matchMedia; +}; + const getContent = (fixture: ComponentFixture): HTMLElement => fixture.nativeElement.querySelector('.kbq-clamped-text__content'); @@ -95,6 +117,7 @@ const isCollapsed = (fixture: ComponentFixture): boolean => @@ -110,6 +133,7 @@ class ClampedTextTest { readonly isCollapsed = signal(undefined); readonly rows = signal(5); readonly scrollOnCollapse = signal(true); + readonly duration = signal(100); onCollapseChanged(collapsed: boolean | undefined): void { this.isCollapsed.set(collapsed); @@ -372,5 +396,101 @@ describe('KbqClampedText', () => { expect(scrollIntoView).not.toHaveBeenCalled(); }); }); + + describe('animation', () => { + const COLLAPSED_HEIGHT = 100; + const EXPANDED_HEIGHT = 300; + + beforeEach(() => { + vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function ( + this: HTMLElement + ) { + const height = this.classList.contains('kbq-clamped-text__content_collapsed') + ? COLLAPSED_HEIGHT + : EXPANDED_HEIGHT; + + return { height } as DOMRect; + }); + }); + + afterEach(restoreAnimationEnvironment); + + it('should animate block-size from the old height to the new one on expand', async () => { + const animate = mockAnimate(); + const fixture = await createMeasuredComponent(ClampedTextTest); + + requireToggle(fixture).click(); + await fixture.whenStable(); + + expect(animate).toHaveBeenCalledTimes(1); + expect(animate).toHaveBeenCalledWith( + { blockSize: [`${COLLAPSED_HEIGHT}px`, `${EXPANDED_HEIGHT}px`], overflow: ['clip', 'clip'] }, + { duration: 100, easing: 'ease-in-out' } + ); + }); + + it('should animate block-size back on collapse', async () => { + const animate = mockAnimate(); + const fixture = await createMeasuredComponent(ClampedTextTest); + + requireToggle(fixture).click(); + await fixture.whenStable(); + requireToggle(fixture).click(); + await fixture.whenStable(); + + expect(animate).toHaveBeenLastCalledWith( + { blockSize: [`${EXPANDED_HEIGHT}px`, `${COLLAPSED_HEIGHT}px`], overflow: ['clip', 'clip'] }, + { duration: 100, easing: 'ease-in-out' } + ); + }); + + it('should respect the duration', async () => { + const animate = mockAnimate(); + const fixture = await createMeasuredComponent(ClampedTextTest); + + fixture.componentInstance.duration.set(250); + await fixture.whenStable(); + + requireToggle(fixture).click(); + await fixture.whenStable(); + + expect(animate).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ duration: 250 })); + }); + + it('should not animate when the height does not change', async () => { + const animate = mockAnimate(); + const fixture = await createMeasuredComponent(ClampedTextTest); + + vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockReturnValue({ height: 100 } as DOMRect); + + requireToggle(fixture).click(); + await fixture.whenStable(); + + expect(animate).not.toHaveBeenCalled(); + }); + + it('should not animate when the user prefers reduced motion', async () => { + const animate = mockAnimate(); + + mockReducedMotion(); + + const fixture = await createMeasuredComponent(ClampedTextTest); + + requireToggle(fixture).click(); + await fixture.whenStable(); + + expect(animate).not.toHaveBeenCalled(); + expect(isCollapsed(fixture)).toBe(false); + }); + + it('should not animate when the content is measured again', async () => { + const animate = mockAnimate(); + const fixture = await createMeasuredComponent(ClampedTextTest); + + await measure(fixture); + + expect(animate).not.toHaveBeenCalled(); + }); + }); }); }); diff --git a/packages/components/clamped-text/clamped-text.ts b/packages/components/clamped-text/clamped-text.ts index 6c467c43f3..86c877fdd5 100644 --- a/packages/components/clamped-text/clamped-text.ts +++ b/packages/components/clamped-text/clamped-text.ts @@ -27,6 +27,7 @@ import { KbqIcon } from '@koobiq/components/icon'; import { KbqLink, KbqLinkPrefix } from '@koobiq/components/link'; import { debounce, timer } from 'rxjs'; import { KbqClampedListTrigger } from './clamped-list'; +import { kbqInjectCollapseAnimation } from './collapse-animation'; import { KbqClamped, KbqClampedRoot, @@ -105,6 +106,11 @@ export class KbqClampedText implements KbqClamped, AfterViewInit { */ readonly scrollOnCollapse = input(true, { transform: booleanAttribute }); + /** + * Duration of the expand and collapse animation in milliseconds. Defaults to `100`. + */ + readonly duration = input(100, { transform: numberAttribute }); + /** Id of the clamped region, published by the toggle as its `aria-controls`. */ readonly contentId = inject(_IdGenerator).getId('kbq-clamped-text-content-'); @@ -139,6 +145,7 @@ export class KbqClampedText implements KbqClamped, AfterViewInit { private readonly elementRef = inject>(ElementRef); private readonly resizeObserver = inject(SharedResizeObserver); private readonly platform = inject(Platform); + private readonly animateCollapse = kbqInjectCollapseAnimation(); /** * Clamped text locale configuration. @@ -169,7 +176,9 @@ export class KbqClampedText implements KbqClamped, AfterViewInit { // Untracked: called from a consumer's `effect()`, the collapsed state read here would make the effect toggle // again on every change it makes. untracked(() => { - this.isCollapsed.update((state) => !(state ?? true)); + this.animateCollapse(this.textContainer().nativeElement, this.duration(), () => + this.isCollapsed.update((state) => !(state ?? true)) + ); if (this.scrollOnCollapse() && this.collapsedState()) { afterNextRender( diff --git a/packages/components/clamped-text/collapse-animation.ts b/packages/components/clamped-text/collapse-animation.ts new file mode 100644 index 0000000000..31a6e17f8a --- /dev/null +++ b/packages/components/clamped-text/collapse-animation.ts @@ -0,0 +1,50 @@ +import { afterNextRender, inject, Injector, runInInjectionContext } from '@angular/core'; +import { kbqAnimationsDisabled } from '@koobiq/components/core'; + +/** + * Creates a runner that animates `block-size` of an element across a state change made by the user. + * The runner does nothing extra when the Web Animations API is missing, the motion is turned off, or the + * height did not change. A running animation is cancelled and the next one starts from the visible height. + * + * Must be called in an injection context. + */ +export function kbqInjectCollapseAnimation(): (element: HTMLElement, duration: number, change: () => void) => void { + const injector = inject(Injector); + let animation: Animation | undefined; + + return (element, duration, change) => { + if (typeof element.animate !== 'function' || runInInjectionContext(injector, kbqAnimationsDisabled)) { + change(); + + return; + } + + const from = element.getBoundingClientRect().height; + + animation?.cancel(); + change(); + + afterNextRender( + () => { + const to = element.getBoundingClientRect().height; + + if (from === to) return; + + const current = element.animate( + { blockSize: [`${from}px`, `${to}px`], overflow: ['clip', 'clip'] }, + { duration, easing: 'ease-in-out' } + ); + + animation = current; + + const release = () => { + if (animation === current) animation = undefined; + }; + + current.onfinish = release; + current.oncancel = release; + }, + { injector } + ); + }; +} diff --git a/tools/public_api_guard/components/clamped-text.api.md b/tools/public_api_guard/components/clamped-text.api.md index 63ceb92d1c..b93edf896a 100644 --- a/tools/public_api_guard/components/clamped-text.api.md +++ b/tools/public_api_guard/components/clamped-text.api.md @@ -30,6 +30,7 @@ export interface KbqClamped { export class KbqClampedList implements KbqClamped { readonly collapsedVisibleCount: _angular_core.InputSignalWithTransform; readonly contentId: string; + readonly duration: _angular_core.InputSignalWithTransform; readonly exceededItemCount: _angular_core.Signal; readonly hasToggle: _angular_core.Signal; readonly hiddenThreshold: _angular_core.InputSignalWithTransform; @@ -40,7 +41,7 @@ export class KbqClampedList implements KbqClamped { toggle(event: Event): void; readonly visibleItems: _angular_core.Signal; // (undocumented) - static ɵdir: _angular_core.ɵɵDirectiveDeclaration, "[kbqClampedList]", ["kbqClampedList"], { "isCollapsed": { "alias": "isCollapsed"; "required": false; "isSignal": true; }; "items": { "alias": "items"; "required": false; "isSignal": true; }; "collapsedVisibleCount": { "alias": "collapsedVisibleCount"; "required": false; "isSignal": true; }; "hiddenThreshold": { "alias": "hiddenThreshold"; "required": false; "isSignal": true; }; }, { "isCollapsed": "isCollapsedChange"; }, never, never, true, [{ directive: typeof _koobiq_components_core.KbqLocaleOverridesDirective; inputs: { "kbqLocaleOverrides": "localeOverrides"; }; outputs: {}; }]>; + static ɵdir: _angular_core.ɵɵDirectiveDeclaration, "[kbqClampedList]", ["kbqClampedList"], { "isCollapsed": { "alias": "isCollapsed"; "required": false; "isSignal": true; }; "items": { "alias": "items"; "required": false; "isSignal": true; }; "collapsedVisibleCount": { "alias": "collapsedVisibleCount"; "required": false; "isSignal": true; }; "hiddenThreshold": { "alias": "hiddenThreshold"; "required": false; "isSignal": true; }; "duration": { "alias": "duration"; "required": false; "isSignal": true; }; }, { "isCollapsed": "isCollapsedChange"; }, never, never, true, [{ directive: typeof _koobiq_components_core.KbqLocaleOverridesDirective; inputs: { "kbqLocaleOverrides": "localeOverrides"; }; outputs: {}; }]>; // (undocumented) static ɵfac: _angular_core.ɵɵFactoryDeclaration, never>; } @@ -65,6 +66,7 @@ export class KbqClampedText implements KbqClamped, AfterViewInit { protected readonly collapsedState: Signal; readonly contentId: string; readonly debounceTime: _angular_core.InputSignalWithTransform; + readonly duration: _angular_core.InputSignalWithTransform; readonly hasToggle: Signal; readonly isCollapsed: _angular_core.ModelSignal; readonly localeConfiguration: Signal<_koobiq_components_core.KbqClampedTextLocaleConfiguration>; @@ -76,7 +78,7 @@ export class KbqClampedText implements KbqClamped, AfterViewInit { protected readonly textContainer: Signal>; toggle(event: Event): void; // (undocumented) - static ɵcmp: _angular_core.ɵɵComponentDeclaration; + static ɵcmp: _angular_core.ɵɵComponentDeclaration; // (undocumented) static ɵfac: _angular_core.ɵɵFactoryDeclaration; } From 3895b01dc95a49b5e5785ac3d3b3f2d3ec540b69 Mon Sep 17 00:00:00 2001 From: Nikita Guryev Date: Tue, 27 Oct 2026 18:45:56 +0300 Subject: [PATCH 2/5] fix(clamped-text): narrow the type of the duration input (#DS-5644) Co-Authored-By: Claude Sonnet 5.5 --- packages/components/clamped-text/clamped-list.ts | 2 +- packages/components/clamped-text/clamped-text.ts | 2 +- tools/public_api_guard/components/clamped-text.api.md | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/components/clamped-text/clamped-list.ts b/packages/components/clamped-text/clamped-list.ts index 15641bb890..4f902979d2 100644 --- a/packages/components/clamped-text/clamped-list.ts +++ b/packages/components/clamped-text/clamped-list.ts @@ -44,7 +44,7 @@ export class KbqClampedList implements KbqClamped { /** * Duration of the expand and collapse animation in milliseconds. Defaults to `100`. */ - readonly duration = input(100, { transform: numberAttribute }); + readonly duration = input(100, { transform: numberAttribute }); /** Id the trigger points its `aria-controls` at. Keeps an id the host already carries. */ readonly contentId: string = this.nativeElement.id || inject(_IdGenerator).getId('kbq-clamped-list-'); diff --git a/packages/components/clamped-text/clamped-text.ts b/packages/components/clamped-text/clamped-text.ts index 86c877fdd5..1ef1a2b643 100644 --- a/packages/components/clamped-text/clamped-text.ts +++ b/packages/components/clamped-text/clamped-text.ts @@ -109,7 +109,7 @@ export class KbqClampedText implements KbqClamped, AfterViewInit { /** * Duration of the expand and collapse animation in milliseconds. Defaults to `100`. */ - readonly duration = input(100, { transform: numberAttribute }); + readonly duration = input(100, { transform: numberAttribute }); /** Id of the clamped region, published by the toggle as its `aria-controls`. */ readonly contentId = inject(_IdGenerator).getId('kbq-clamped-text-content-'); diff --git a/tools/public_api_guard/components/clamped-text.api.md b/tools/public_api_guard/components/clamped-text.api.md index b93edf896a..da28967176 100644 --- a/tools/public_api_guard/components/clamped-text.api.md +++ b/tools/public_api_guard/components/clamped-text.api.md @@ -30,7 +30,7 @@ export interface KbqClamped { export class KbqClampedList implements KbqClamped { readonly collapsedVisibleCount: _angular_core.InputSignalWithTransform; readonly contentId: string; - readonly duration: _angular_core.InputSignalWithTransform; + readonly duration: _angular_core.InputSignalWithTransform; readonly exceededItemCount: _angular_core.Signal; readonly hasToggle: _angular_core.Signal; readonly hiddenThreshold: _angular_core.InputSignalWithTransform; @@ -66,7 +66,7 @@ export class KbqClampedText implements KbqClamped, AfterViewInit { protected readonly collapsedState: Signal; readonly contentId: string; readonly debounceTime: _angular_core.InputSignalWithTransform; - readonly duration: _angular_core.InputSignalWithTransform; + readonly duration: _angular_core.InputSignalWithTransform; readonly hasToggle: Signal; readonly isCollapsed: _angular_core.ModelSignal; readonly localeConfiguration: Signal<_koobiq_components_core.KbqClampedTextLocaleConfiguration>; From 68ece1f253baa0eaaf0dfa537c0825c0ba48c1ea Mon Sep 17 00:00:00 2001 From: Nikita Guryev Date: Wed, 28 Oct 2026 10:43:42 +0300 Subject: [PATCH 3/5] fix(clamped-text): ease the space after the content instead of its block-size (#DS-5644) Co-Authored-By: Claude Sonnet 5.5 --- .../clamped-text/clamped-list.spec.ts | 6 ++-- .../clamped-text/clamped-text.spec.ts | 15 +++++---- .../clamped-text/collapse-animation.ts | 33 ++++++++++++++----- 3 files changed, 37 insertions(+), 17 deletions(-) diff --git a/packages/components/clamped-text/clamped-list.spec.ts b/packages/components/clamped-text/clamped-list.spec.ts index a8e5344bb8..f0f8affad3 100644 --- a/packages/components/clamped-text/clamped-list.spec.ts +++ b/packages/components/clamped-text/clamped-list.spec.ts @@ -220,7 +220,7 @@ describe('KbqClampedList', () => { afterEach(restoreAnimationEnvironment); - it('should animate block-size of the host between the heights before and after the toggle', async () => { + it('should ease the space after the host between the heights before and after the toggle', async () => { const animate = mockAnimate(); getTrigger(debugElement).nativeElement.click(); @@ -228,8 +228,8 @@ describe('KbqClampedList', () => { expect(animate).toHaveBeenCalledTimes(1); expect(animate).toHaveBeenCalledWith( - { blockSize: ['100px', '170px'], overflow: ['clip', 'clip'] }, - { duration: 100, easing: 'ease-in-out' } + { marginBlockEnd: ['-70px', '0px'], clipPath: ['inset(0 0 70px 0)', 'inset(0 0 0 0)'] }, + { duration: 100, easing: 'ease-in-out', composite: 'add' } ); }); diff --git a/packages/components/clamped-text/clamped-text.spec.ts b/packages/components/clamped-text/clamped-text.spec.ts index cf1eb7669d..b381c38eea 100644 --- a/packages/components/clamped-text/clamped-text.spec.ts +++ b/packages/components/clamped-text/clamped-text.spec.ts @@ -415,7 +415,7 @@ describe('KbqClampedText', () => { afterEach(restoreAnimationEnvironment); - it('should animate block-size from the old height to the new one on expand', async () => { + it('should ease the space after the content and reveal it from the top on expand', async () => { const animate = mockAnimate(); const fixture = await createMeasuredComponent(ClampedTextTest); @@ -424,12 +424,15 @@ describe('KbqClampedText', () => { expect(animate).toHaveBeenCalledTimes(1); expect(animate).toHaveBeenCalledWith( - { blockSize: [`${COLLAPSED_HEIGHT}px`, `${EXPANDED_HEIGHT}px`], overflow: ['clip', 'clip'] }, - { duration: 100, easing: 'ease-in-out' } + { + marginBlockEnd: ['-200px', '0px'], + clipPath: ['inset(0 0 200px 0)', 'inset(0 0 0 0)'] + }, + { duration: 100, easing: 'ease-in-out', composite: 'add' } ); }); - it('should animate block-size back on collapse', async () => { + it('should ease the space after the content on collapse', async () => { const animate = mockAnimate(); const fixture = await createMeasuredComponent(ClampedTextTest); @@ -439,8 +442,8 @@ describe('KbqClampedText', () => { await fixture.whenStable(); expect(animate).toHaveBeenLastCalledWith( - { blockSize: [`${EXPANDED_HEIGHT}px`, `${COLLAPSED_HEIGHT}px`], overflow: ['clip', 'clip'] }, - { duration: 100, easing: 'ease-in-out' } + { marginBlockEnd: ['200px', '0px'] }, + { duration: 100, easing: 'ease-in-out', composite: 'add' } ); }); diff --git a/packages/components/clamped-text/collapse-animation.ts b/packages/components/clamped-text/collapse-animation.ts index 31a6e17f8a..5b5747ff18 100644 --- a/packages/components/clamped-text/collapse-animation.ts +++ b/packages/components/clamped-text/collapse-animation.ts @@ -1,17 +1,23 @@ import { afterNextRender, inject, Injector, runInInjectionContext } from '@angular/core'; -import { kbqAnimationsDisabled } from '@koobiq/components/core'; +import { KBQ_WINDOW, kbqAnimationsDisabled } from '@koobiq/components/core'; /** - * Creates a runner that animates `block-size` of an element across a state change made by the user. - * The runner does nothing extra when the Web Animations API is missing, the motion is turned off, or the - * height did not change. A running animation is cancelled and the next one starts from the visible height. + * Creates a runner that animates the height an element takes in the layout across a state change made by the user. + * + * The element is already laid out in its final size, so its children are never squeezed or stretched: the space + * after it is eased with `margin-block-end` and, when it grows, the part not yet revealed is cut off with `clip-path`. + * The runner only makes the change when the Web Animations API is missing, the motion is turned off, or the height + * did not change. A running animation is cancelled and the next one starts from the visible height. * * Must be called in an injection context. */ export function kbqInjectCollapseAnimation(): (element: HTMLElement, duration: number, change: () => void) => void { const injector = inject(Injector); + const window = inject(KBQ_WINDOW); let animation: Animation | undefined; + const getMarginEnd = (element: HTMLElement) => parseFloat(window.getComputedStyle(element).marginBlockEnd) || 0; + return (element, duration, change) => { if (typeof element.animate !== 'function' || runInInjectionContext(injector, kbqAnimationsDisabled)) { change(); @@ -19,9 +25,17 @@ export function kbqInjectCollapseAnimation(): (element: HTMLElement, duration: n return; } - const from = element.getBoundingClientRect().height; + let marginOffset = 0; + + if (animation) { + const animatedMargin = getMarginEnd(element); + + animation.cancel(); + marginOffset = animatedMargin - getMarginEnd(element); + } + + const from = element.getBoundingClientRect().height + marginOffset; - animation?.cancel(); change(); afterNextRender( @@ -31,8 +45,11 @@ export function kbqInjectCollapseAnimation(): (element: HTMLElement, duration: n if (from === to) return; const current = element.animate( - { blockSize: [`${from}px`, `${to}px`], overflow: ['clip', 'clip'] }, - { duration, easing: 'ease-in-out' } + { + marginBlockEnd: [`${from - to}px`, '0px'], + ...(to > from && { clipPath: [`inset(0 0 ${to - from}px 0)`, 'inset(0 0 0 0)'] }) + }, + { duration, easing: 'ease-in-out', composite: 'add' } ); animation = current; From 32ef0e23da4ccc4709e8d9b084dd1ec5961a2479 Mon Sep 17 00:00:00 2001 From: Nikita Guryev Date: Wed, 28 Oct 2026 11:43:42 +0300 Subject: [PATCH 4/5] fix(clamped-text): animate block-size again and keep the children from squeezing (#DS-5644) Co-Authored-By: Claude Sonnet 5.5 --- .../clamped-text/clamped-list.spec.ts | 12 +++-- .../clamped-text/clamped-text.spec.ts | 21 ++++---- .../clamped-text/collapse-animation.ts | 51 +++++++------------ 3 files changed, 38 insertions(+), 46 deletions(-) diff --git a/packages/components/clamped-text/clamped-list.spec.ts b/packages/components/clamped-text/clamped-list.spec.ts index f0f8affad3..b5bc940f83 100644 --- a/packages/components/clamped-text/clamped-list.spec.ts +++ b/packages/components/clamped-text/clamped-list.spec.ts @@ -220,16 +220,18 @@ describe('KbqClampedList', () => { afterEach(restoreAnimationEnvironment); - it('should ease the space after the host between the heights before and after the toggle', async () => { + it('should animate block-size of the host between the heights before and after the toggle', async () => { const animate = mockAnimate(); getTrigger(debugElement).nativeElement.click(); await fixture.whenStable(); - - expect(animate).toHaveBeenCalledTimes(1); expect(animate).toHaveBeenCalledWith( - { marginBlockEnd: ['-70px', '0px'], clipPath: ['inset(0 0 70px 0)', 'inset(0 0 0 0)'] }, - { duration: 100, easing: 'ease-in-out', composite: 'add' } + { + blockSize: ['100px', '170px'], + overflow: ['clip', 'clip'], + alignContent: ['flex-start', 'flex-start'] + }, + { duration: 100, easing: 'ease-in-out' } ); }); diff --git a/packages/components/clamped-text/clamped-text.spec.ts b/packages/components/clamped-text/clamped-text.spec.ts index b381c38eea..a93e3aa448 100644 --- a/packages/components/clamped-text/clamped-text.spec.ts +++ b/packages/components/clamped-text/clamped-text.spec.ts @@ -415,24 +415,23 @@ describe('KbqClampedText', () => { afterEach(restoreAnimationEnvironment); - it('should ease the space after the content and reveal it from the top on expand', async () => { + it('should animate block-size from the old height to the new one on expand', async () => { const animate = mockAnimate(); const fixture = await createMeasuredComponent(ClampedTextTest); requireToggle(fixture).click(); await fixture.whenStable(); - - expect(animate).toHaveBeenCalledTimes(1); expect(animate).toHaveBeenCalledWith( { - marginBlockEnd: ['-200px', '0px'], - clipPath: ['inset(0 0 200px 0)', 'inset(0 0 0 0)'] + blockSize: [`${COLLAPSED_HEIGHT}px`, `${EXPANDED_HEIGHT}px`], + overflow: ['clip', 'clip'], + alignContent: ['flex-start', 'flex-start'] }, - { duration: 100, easing: 'ease-in-out', composite: 'add' } + { duration: 100, easing: 'ease-in-out' } ); }); - it('should ease the space after the content on collapse', async () => { + it('should animate block-size back on collapse', async () => { const animate = mockAnimate(); const fixture = await createMeasuredComponent(ClampedTextTest); @@ -442,8 +441,12 @@ describe('KbqClampedText', () => { await fixture.whenStable(); expect(animate).toHaveBeenLastCalledWith( - { marginBlockEnd: ['200px', '0px'] }, - { duration: 100, easing: 'ease-in-out', composite: 'add' } + { + blockSize: [`${EXPANDED_HEIGHT}px`, `${COLLAPSED_HEIGHT}px`], + overflow: ['clip', 'clip'], + alignContent: ['flex-start', 'flex-start'] + }, + { duration: 100, easing: 'ease-in-out' } ); }); diff --git a/packages/components/clamped-text/collapse-animation.ts b/packages/components/clamped-text/collapse-animation.ts index 5b5747ff18..f41767a672 100644 --- a/packages/components/clamped-text/collapse-animation.ts +++ b/packages/components/clamped-text/collapse-animation.ts @@ -1,22 +1,19 @@ import { afterNextRender, inject, Injector, runInInjectionContext } from '@angular/core'; -import { KBQ_WINDOW, kbqAnimationsDisabled } from '@koobiq/components/core'; +import { kbqAnimationsDisabled } from '@koobiq/components/core'; /** - * Creates a runner that animates the height an element takes in the layout across a state change made by the user. + * Creates a runner that animates `block-size` of an element across a state change made by the user. + * The runner only makes the change when the Web Animations API is missing, the motion is turned off, or the + * height did not change. A running animation is cancelled and the next one starts from the visible height. * - * The element is already laid out in its final size, so its children are never squeezed or stretched: the space - * after it is eased with `margin-block-end` and, when it grows, the part not yet revealed is cut off with `clip-path`. - * The runner only makes the change when the Web Animations API is missing, the motion is turned off, or the height - * did not change. A running animation is cancelled and the next one starts from the visible height. + * A fixed `block-size` makes a flex host squeeze its children when it is smaller than the content and spread + * wrapped lines when it is larger, so the host keeps its children's size and packs the lines to the start. * * Must be called in an injection context. */ export function kbqInjectCollapseAnimation(): (element: HTMLElement, duration: number, change: () => void) => void { const injector = inject(Injector); - const window = inject(KBQ_WINDOW); - let animation: Animation | undefined; - - const getMarginEnd = (element: HTMLElement) => parseFloat(window.getComputedStyle(element).marginBlockEnd) || 0; + let animations: Animation[] = []; return (element, duration, change) => { if (typeof element.animate !== 'function' || runInInjectionContext(injector, kbqAnimationsDisabled)) { @@ -25,17 +22,9 @@ export function kbqInjectCollapseAnimation(): (element: HTMLElement, duration: n return; } - let marginOffset = 0; - - if (animation) { - const animatedMargin = getMarginEnd(element); - - animation.cancel(); - marginOffset = animatedMargin - getMarginEnd(element); - } - - const from = element.getBoundingClientRect().height + marginOffset; + const from = element.getBoundingClientRect().height; + animations.forEach((animation) => animation.cancel()); change(); afterNextRender( @@ -44,22 +33,20 @@ export function kbqInjectCollapseAnimation(): (element: HTMLElement, duration: n if (from === to) return; - const current = element.animate( + const timing = { duration, easing: 'ease-in-out' }; + const children = Array.from(element.children).map((child) => + child.animate({ flexShrink: [0, 0] }, timing) + ); + const host = element.animate( { - marginBlockEnd: [`${from - to}px`, '0px'], - ...(to > from && { clipPath: [`inset(0 0 ${to - from}px 0)`, 'inset(0 0 0 0)'] }) + blockSize: [`${from}px`, `${to}px`], + overflow: ['clip', 'clip'], + alignContent: ['flex-start', 'flex-start'] }, - { duration, easing: 'ease-in-out', composite: 'add' } + timing ); - animation = current; - - const release = () => { - if (animation === current) animation = undefined; - }; - - current.onfinish = release; - current.oncancel = release; + animations = [host, ...children]; }, { injector } ); From 68a86a8b3ebd19e8305903179424e14c362c4e02 Mon Sep 17 00:00:00 2001 From: Nikita Guryev Date: Wed, 28 Oct 2026 16:24:16 +0300 Subject: [PATCH 5/5] fix(clamped-text): guard the animation duration and settle the scroll with it (#DS-5644) - skip the animation when duration is not a non-negative number instead of letting element.animate throw - cancel the running animation when the injector is destroyed - scroll into view only after the collapse animation finishes - document that only changes made through the disclosure toggle animate --- .../clamped-text/clamped-list.spec.ts | 30 ++++-- .../components/clamped-text/clamped-list.ts | 4 +- .../clamped-text/clamped-text.spec.ts | 65 +++++++++++-- .../components/clamped-text/clamped-text.ts | 14 ++- .../clamped-text/collapse-animation.ts | 94 ++++++++++++------- 5 files changed, 151 insertions(+), 56 deletions(-) diff --git a/packages/components/clamped-text/clamped-list.spec.ts b/packages/components/clamped-text/clamped-list.spec.ts index b5bc940f83..0b9bcb688a 100644 --- a/packages/components/clamped-text/clamped-list.spec.ts +++ b/packages/components/clamped-text/clamped-list.spec.ts @@ -9,11 +9,14 @@ const AXE_TIMEOUT = 15000; /** jsdom has no Web Animations API, so `animate` is supplied for the specs that observe the animation. */ const mockAnimate = () => { - const animate = vi.fn(() => ({ cancel: vi.fn() }) as unknown as Animation); + let resolveFinished!: () => void; + const finished = new Promise((resolve) => (resolveFinished = resolve)); + const cancel = vi.fn(); + const animate = vi.fn(() => ({ cancel, finished }) as unknown as Animation); Object.defineProperty(HTMLElement.prototype, 'animate', { value: animate, configurable: true, writable: true }); - return animate; + return { animate, cancel, resolveFinished }; }; const mockReducedMotion = () => { @@ -221,7 +224,7 @@ describe('KbqClampedList', () => { afterEach(restoreAnimationEnvironment); it('should animate block-size of the host between the heights before and after the toggle', async () => { - const animate = mockAnimate(); + const { animate } = mockAnimate(); getTrigger(debugElement).nativeElement.click(); await fixture.whenStable(); @@ -236,7 +239,7 @@ describe('KbqClampedList', () => { }); it('should respect the duration', async () => { - const animate = mockAnimate(); + const { animate } = mockAnimate(); fixture.componentInstance.duration.set(250); fixture.detectChanges(); @@ -248,7 +251,7 @@ describe('KbqClampedList', () => { }); it('should not animate when the height does not change', async () => { - const animate = mockAnimate(); + const { animate } = mockAnimate(); vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockReturnValue({ height: 100 } as DOMRect); @@ -259,7 +262,7 @@ describe('KbqClampedList', () => { }); it('should not animate when the user prefers reduced motion', async () => { - const animate = mockAnimate(); + const { animate } = mockAnimate(); mockReducedMotion(); @@ -271,12 +274,25 @@ describe('KbqClampedList', () => { }); it('should not animate when the items change without a toggle', async () => { - const animate = mockAnimate(); + const { animate } = mockAnimate(); fixture.componentInstance.items.set(Array.from({ length: 30 }, (_, i) => `Item ${i + 1}`)); await fixture.whenStable(); expect(animate).not.toHaveBeenCalled(); }); + + it.each([NaN, -1])('should not animate with the invalid duration %s', async (duration) => { + const { animate } = mockAnimate(); + + fixture.componentInstance.duration.set(duration); + fixture.detectChanges(); + + getTrigger(debugElement).nativeElement.click(); + await fixture.whenStable(); + + expect(animate).not.toHaveBeenCalled(); + expect(getItems(debugElement).length).toBe(fixture.componentInstance.items().length); + }); }); }); diff --git a/packages/components/clamped-text/clamped-list.ts b/packages/components/clamped-text/clamped-list.ts index 4f902979d2..0c84b3417e 100644 --- a/packages/components/clamped-text/clamped-list.ts +++ b/packages/components/clamped-text/clamped-list.ts @@ -42,7 +42,9 @@ export class KbqClampedList implements KbqClamped { readonly hiddenThreshold = input(6, { transform: numberAttribute }); /** - * Duration of the expand and collapse animation in milliseconds. Defaults to `100`. + * Duration of the expand and collapse animation in milliseconds. + * Only state changes made through the disclosure toggle animate: programmatic writes of + * `isCollapsed` and changes of `items` switch instantly. Defaults to `100`. */ readonly duration = input(100, { transform: numberAttribute }); diff --git a/packages/components/clamped-text/clamped-text.spec.ts b/packages/components/clamped-text/clamped-text.spec.ts index a93e3aa448..37a9a9d4d8 100644 --- a/packages/components/clamped-text/clamped-text.spec.ts +++ b/packages/components/clamped-text/clamped-text.spec.ts @@ -71,11 +71,14 @@ const createMeasuredComponent = async (component: Type): Promise { - const animate = vi.fn(() => ({ cancel: vi.fn() }) as unknown as Animation); + let resolveFinished!: () => void; + const finished = new Promise((resolve) => (resolveFinished = resolve)); + const cancel = vi.fn(); + const animate = vi.fn(() => ({ cancel, finished }) as unknown as Animation); Object.defineProperty(HTMLElement.prototype, 'animate', { value: animate, configurable: true, writable: true }); - return animate; + return { animate, cancel, resolveFinished }; }; const mockReducedMotion = () => { @@ -416,7 +419,7 @@ describe('KbqClampedText', () => { afterEach(restoreAnimationEnvironment); it('should animate block-size from the old height to the new one on expand', async () => { - const animate = mockAnimate(); + const { animate } = mockAnimate(); const fixture = await createMeasuredComponent(ClampedTextTest); requireToggle(fixture).click(); @@ -432,7 +435,7 @@ describe('KbqClampedText', () => { }); it('should animate block-size back on collapse', async () => { - const animate = mockAnimate(); + const { animate } = mockAnimate(); const fixture = await createMeasuredComponent(ClampedTextTest); requireToggle(fixture).click(); @@ -451,7 +454,7 @@ describe('KbqClampedText', () => { }); it('should respect the duration', async () => { - const animate = mockAnimate(); + const { animate } = mockAnimate(); const fixture = await createMeasuredComponent(ClampedTextTest); fixture.componentInstance.duration.set(250); @@ -464,7 +467,7 @@ describe('KbqClampedText', () => { }); it('should not animate when the height does not change', async () => { - const animate = mockAnimate(); + const { animate } = mockAnimate(); const fixture = await createMeasuredComponent(ClampedTextTest); vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockReturnValue({ height: 100 } as DOMRect); @@ -476,7 +479,7 @@ describe('KbqClampedText', () => { }); it('should not animate when the user prefers reduced motion', async () => { - const animate = mockAnimate(); + const { animate } = mockAnimate(); mockReducedMotion(); @@ -490,13 +493,59 @@ describe('KbqClampedText', () => { }); it('should not animate when the content is measured again', async () => { - const animate = mockAnimate(); + const { animate } = mockAnimate(); const fixture = await createMeasuredComponent(ClampedTextTest); await measure(fixture); expect(animate).not.toHaveBeenCalled(); }); + + it.each([NaN, -1])('should not animate with the invalid duration %s', async (duration) => { + const { animate } = mockAnimate(); + const fixture = await createMeasuredComponent(ClampedTextTest); + + fixture.componentInstance.duration.set(duration); + await fixture.whenStable(); + + requireToggle(fixture).click(); + await fixture.whenStable(); + + expect(animate).not.toHaveBeenCalled(); + expect(isCollapsed(fixture)).toBe(false); + }); + + it('should cancel a running animation when the component is destroyed', async () => { + const { cancel } = mockAnimate(); + const fixture = await createMeasuredComponent(ClampedTextTest); + + requireToggle(fixture).click(); + await fixture.whenStable(); + + expect(cancel).not.toHaveBeenCalled(); + + fixture.destroy(); + + expect(cancel).toHaveBeenCalled(); + }); + + it('should scroll into view once the collapse animation finishes', async () => { + const { resolveFinished } = mockAnimate(); + const fixture = await createMeasuredComponent(ClampedTextTest); + const scrollIntoView = vi.spyOn(Element.prototype, 'scrollIntoView').mockImplementation(() => {}); + + requireToggle(fixture).click(); + await fixture.whenStable(); + requireToggle(fixture).click(); + await fixture.whenStable(); + + expect(scrollIntoView).not.toHaveBeenCalled(); + + resolveFinished(); + await new Promise((resolve) => setTimeout(resolve)); + + expect(scrollIntoView).toHaveBeenCalledWith({ block: 'nearest', inline: 'nearest' }); + }); }); }); }); diff --git a/packages/components/clamped-text/clamped-text.ts b/packages/components/clamped-text/clamped-text.ts index 1ef1a2b643..6f5ff06e9d 100644 --- a/packages/components/clamped-text/clamped-text.ts +++ b/packages/components/clamped-text/clamped-text.ts @@ -2,7 +2,6 @@ import { _IdGenerator } from '@angular/cdk/a11y'; import { SharedResizeObserver } from '@angular/cdk/observers/private'; import { Platform } from '@angular/cdk/platform'; import { - afterNextRender, AfterViewInit, booleanAttribute, ChangeDetectionStrategy, @@ -11,7 +10,6 @@ import { DestroyRef, ElementRef, inject, - Injector, input, model, numberAttribute, @@ -107,7 +105,9 @@ export class KbqClampedText implements KbqClamped, AfterViewInit { readonly scrollOnCollapse = input(true, { transform: booleanAttribute }); /** - * Duration of the expand and collapse animation in milliseconds. Defaults to `100`. + * Duration of the expand and collapse animation in milliseconds. + * Only state changes made through the disclosure toggle animate: programmatic writes of + * `isCollapsed` and re-measurements switch instantly. Defaults to `100`. */ readonly duration = input(100, { transform: numberAttribute }); @@ -141,7 +141,6 @@ export class KbqClampedText implements KbqClamped, AfterViewInit { }); private readonly destroyRef = inject(DestroyRef); - private readonly injector = inject(Injector); private readonly elementRef = inject>(ElementRef); private readonly resizeObserver = inject(SharedResizeObserver); private readonly platform = inject(Platform); @@ -176,14 +175,13 @@ export class KbqClampedText implements KbqClamped, AfterViewInit { // Untracked: called from a consumer's `effect()`, the collapsed state read here would make the effect toggle // again on every change it makes. untracked(() => { - this.animateCollapse(this.textContainer().nativeElement, this.duration(), () => + const settled = this.animateCollapse(this.textContainer().nativeElement, this.duration(), () => this.isCollapsed.update((state) => !(state ?? true)) ); if (this.scrollOnCollapse() && this.collapsedState()) { - afterNextRender( - () => this.elementRef.nativeElement.scrollIntoView({ block: 'nearest', inline: 'nearest' }), - { injector: this.injector } + settled.then(() => + this.elementRef.nativeElement.scrollIntoView({ block: 'nearest', inline: 'nearest' }) ); } }); diff --git a/packages/components/clamped-text/collapse-animation.ts b/packages/components/clamped-text/collapse-animation.ts index f41767a672..9a865929fe 100644 --- a/packages/components/clamped-text/collapse-animation.ts +++ b/packages/components/clamped-text/collapse-animation.ts @@ -1,25 +1,43 @@ -import { afterNextRender, inject, Injector, runInInjectionContext } from '@angular/core'; +import { afterNextRender, DestroyRef, inject, Injector, runInInjectionContext } from '@angular/core'; import { kbqAnimationsDisabled } from '@koobiq/components/core'; /** - * Creates a runner that animates `block-size` of an element across a state change made by the user. - * The runner only makes the change when the Web Animations API is missing, the motion is turned off, or the - * height did not change. A running animation is cancelled and the next one starts from the visible height. + * Creates a runner that animates `block-size` of an element across a state change made by the user + * and returns a promise that settles when the motion ends or is cancelled. The promise resolves + * right away when the change is not animated: the Web Animations API is missing, the motion is + * turned off, the duration is not a non-negative number, or the height did not change. * - * A fixed `block-size` makes a flex host squeeze its children when it is smaller than the content and spread - * wrapped lines when it is larger, so the host keeps its children's size and packs the lines to the start. + * A running animation is cancelled and the next one starts from the visible height; destroying the + * injector cancels the animation it owns. A fixed `block-size` makes a flex host squeeze its + * children when it is smaller than the content and spread wrapped lines when it is larger, so the + * host keeps its children's size and packs the lines to the start. + * + * The heights come from `getBoundingClientRect`, which reads the border box, so an element with a + * padding or a border under a `content-box` box-sizing snaps when the animation ends. * * Must be called in an injection context. */ -export function kbqInjectCollapseAnimation(): (element: HTMLElement, duration: number, change: () => void) => void { +export function kbqInjectCollapseAnimation(): ( + element: HTMLElement, + duration: number, + change: () => void +) => Promise { const injector = inject(Injector); + const destroyRef = inject(DestroyRef); let animations: Animation[] = []; + destroyRef.onDestroy(() => animations.forEach((animation) => animation.cancel())); + return (element, duration, change) => { - if (typeof element.animate !== 'function' || runInInjectionContext(injector, kbqAnimationsDisabled)) { + if ( + typeof element.animate !== 'function' || + !Number.isFinite(duration) || + duration < 0 || + runInInjectionContext(injector, kbqAnimationsDisabled) + ) { change(); - return; + return Promise.resolve(); } const from = element.getBoundingClientRect().height; @@ -27,28 +45,40 @@ export function kbqInjectCollapseAnimation(): (element: HTMLElement, duration: n animations.forEach((animation) => animation.cancel()); change(); - afterNextRender( - () => { - const to = element.getBoundingClientRect().height; - - if (from === to) return; - - const timing = { duration, easing: 'ease-in-out' }; - const children = Array.from(element.children).map((child) => - child.animate({ flexShrink: [0, 0] }, timing) - ); - const host = element.animate( - { - blockSize: [`${from}px`, `${to}px`], - overflow: ['clip', 'clip'], - alignContent: ['flex-start', 'flex-start'] - }, - timing - ); - - animations = [host, ...children]; - }, - { injector } - ); + return new Promise((resolve) => { + afterNextRender( + () => { + const to = element.getBoundingClientRect().height; + + if (from === to) { + resolve(); + + return; + } + + const timing = { duration, easing: 'ease-in-out' }; + const children = Array.from(element.children).map((child) => + child.animate({ flexShrink: [0, 0] }, timing) + ); + const host = element.animate( + { + blockSize: [`${from}px`, `${to}px`], + overflow: ['clip', 'clip'], + alignContent: ['flex-start', 'flex-start'] + }, + timing + ); + + animations = [host, ...children]; + // A cancelled animation rejects `finished`; the promise settles either way, so the caller's + // follow-up still runs. + host.finished.then( + () => resolve(), + () => resolve() + ); + }, + { injector } + ); + }); }; }