diff --git a/packages/components/clamped-text/clamped-list.spec.ts b/packages/components/clamped-text/clamped-list.spec.ts index e1fb029f86..0b9bcb688a 100644 --- a/packages/components/clamped-text/clamped-list.spec.ts +++ b/packages/components/clamped-text/clamped-list.spec.ts @@ -7,6 +7,31 @@ 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 = () => { + 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, cancel, resolveFinished }; +}; + +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 +56,7 @@ function getHost(debugElement: DebugElement): HTMLElement { [items]="items()" [collapsedVisibleCount]="collapsedVisibleCount()" [hiddenThreshold]="hiddenThreshold()" + [duration]="duration()" > @for (item of clampedList.visibleItems(); track item) { {{ item }} @@ -52,6 +78,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 +211,88 @@ 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).toHaveBeenCalledWith( + { + blockSize: ['100px', '170px'], + overflow: ['clip', 'clip'], + alignContent: ['flex-start', 'flex-start'] + }, + { 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(); + }); + + 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 4620761cfd..0c84b3417e 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,13 @@ export class KbqClampedList implements KbqClamped { */ readonly hiddenThreshold = input(6, { transform: numberAttribute }); + /** + * 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 }); + /** 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 +70,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..37a9a9d4d8 100644 --- a/packages/components/clamped-text/clamped-text.spec.ts +++ b/packages/components/clamped-text/clamped-text.spec.ts @@ -69,6 +69,31 @@ const createMeasuredComponent = async (component: Type): Promise { + 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, cancel, resolveFinished }; +}; + +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 +120,7 @@ const isCollapsed = (fixture: ComponentFixture): boolean => @@ -110,6 +136,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 +399,153 @@ 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).toHaveBeenCalledWith( + { + blockSize: [`${COLLAPSED_HEIGHT}px`, `${EXPANDED_HEIGHT}px`], + overflow: ['clip', 'clip'], + alignContent: ['flex-start', 'flex-start'] + }, + { 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'], + alignContent: ['flex-start', 'flex-start'] + }, + { 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(); + }); + + 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 6c467c43f3..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, @@ -27,6 +25,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 +104,13 @@ export class KbqClampedText implements KbqClamped, AfterViewInit { */ readonly scrollOnCollapse = input(true, { transform: booleanAttribute }); + /** + * 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 }); + /** Id of the clamped region, published by the toggle as its `aria-controls`. */ readonly contentId = inject(_IdGenerator).getId('kbq-clamped-text-content-'); @@ -135,10 +141,10 @@ 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); + private readonly animateCollapse = kbqInjectCollapseAnimation(); /** * Clamped text locale configuration. @@ -169,12 +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.isCollapsed.update((state) => !(state ?? true)); + 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 new file mode 100644 index 0000000000..9a865929fe --- /dev/null +++ b/packages/components/clamped-text/collapse-animation.ts @@ -0,0 +1,84 @@ +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 + * 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 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 +) => 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' || + !Number.isFinite(duration) || + duration < 0 || + runInInjectionContext(injector, kbqAnimationsDisabled) + ) { + change(); + + return Promise.resolve(); + } + + const from = element.getBoundingClientRect().height; + + animations.forEach((animation) => animation.cancel()); + change(); + + 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 } + ); + }); + }; +} diff --git a/tools/public_api_guard/components/clamped-text.api.md b/tools/public_api_guard/components/clamped-text.api.md index 63ceb92d1c..da28967176 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; }