Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
111 changes: 111 additions & 0 deletions packages/components/clamped-text/clamped-list.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>((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'));
}
Expand All @@ -31,6 +56,7 @@ function getHost(debugElement: DebugElement): HTMLElement {
[items]="items()"
[collapsedVisibleCount]="collapsedVisibleCount()"
[hiddenThreshold]="hiddenThreshold()"
[duration]="duration()"
>
@for (item of clampedList.visibleItems(); track item) {
<span class="item">{{ item }}</span>
Expand All @@ -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', () => {
Expand Down Expand Up @@ -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);
});
});
});
11 changes: 10 additions & 1 deletion packages/components/clamped-text/clamped-list.ts
Original file line number Diff line number Diff line change
@@ -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';

/**
Expand All @@ -23,6 +24,7 @@ import { KbqClamped, KbqClampedRoot, kbqInjectClampedTextLocaleConfiguration } f
})
export class KbqClampedList<T> implements KbqClamped {
private readonly nativeElement = kbqInjectNativeElement();
private readonly animateCollapse = kbqInjectCollapseAnimation();

/** Collapsed state: `true` = collapsed, `false` = expanded, `undefined` = expanded. */
readonly isCollapsed = model<boolean>(true);
Expand All @@ -39,6 +41,13 @@ export class KbqClampedList<T> 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<number, number | string | null | undefined>(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-');

Expand All @@ -61,7 +70,7 @@ export class KbqClampedList<T> 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));
}
}

Expand Down
175 changes: 175 additions & 0 deletions packages/components/clamped-text/clamped-text.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,31 @@ const createMeasuredComponent = async <T>(component: Type<T>): Promise<Component
return fixture;
};

/** 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<void>((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<unknown>): HTMLElement =>
fixture.nativeElement.querySelector('.kbq-clamped-text__content');

Expand All @@ -95,6 +120,7 @@ const isCollapsed = (fixture: ComponentFixture<unknown>): boolean =>
<kbq-clamped-text
[rows]="rows()"
[scrollOnCollapse]="scrollOnCollapse()"
[duration]="duration()"
[isCollapsed]="isCollapsed()"
(isCollapsedChange)="onCollapseChanged($event)"
>
Expand All @@ -110,6 +136,7 @@ class ClampedTextTest {
readonly isCollapsed = signal<boolean | undefined>(undefined);
readonly rows = signal(5);
readonly scrollOnCollapse = signal(true);
readonly duration = signal(100);

onCollapseChanged(collapsed: boolean | undefined): void {
this.isCollapsed.set(collapsed);
Expand Down Expand Up @@ -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' });
});
});
});
});
Loading
Loading