Skip to content

Commit

Permalink
feat(animations): introduce transition context
Browse files Browse the repository at this point in the history
  • Loading branch information
maxokorokov committed May 18, 2020
1 parent 2373de3 commit 12d753b
Show file tree
Hide file tree
Showing 2 changed files with 130 additions and 59 deletions.
59 changes: 59 additions & 0 deletions src/util/transition/ngbTransition.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,65 @@ if (isBrowserVisible('ngbRunTransition')) {
expect(completeSpy1).toHaveBeenCalled();
});

it(`should create and allow modifying context when running a new transition`, (done) => {
const startFn = ({classList}: HTMLElement, context: any) => {
classList.remove('ngb-test-show');
expect(context.number).toBe(123);
context.number = 456;
};

element.classList.add('ngb-test-fade');

const ctx = {number: 123};

ngbRunTransition(element, startFn, {animation: true, runningTransition: 'continue', context: ctx})
.subscribe(async() => {
expect(await getComputedStyleAsync(element, 'opacity')).toBe('0');
expect(ctx.number).toBe(456);
done();
});

expect(window.getComputedStyle(element).opacity).toBe('1');
});

it(`should create and allow modifying context when running multiple transitions`, (done) => {
const contextSpy = createSpy();
const startFn = ({classList}: HTMLElement, context: any) => {
classList.add('ngb-test-during');
if (!context.counter) {
context.counter = 0;
}
context.counter++;
contextSpy({...context});

return () => {
classList.remove('ngb-test-during');
classList.add('ngb-test-after');
context.counter = 999;
contextSpy({...context});
};
};

element.classList.add('ngb-test-before');

// first transition
ngbRunTransition(element, startFn, {animation: true, runningTransition: 'stop', context: {text: 'one'}})
.subscribe(() => {}, () => {}, () => {});
expect(contextSpy).toHaveBeenCalledWith({text: 'one', counter: 1});

// second transiiton
ngbRunTransition(element, startFn, {animation: true, runningTransition: 'stop', context: {text: 'two'}})
.subscribe(async() => {
expect(await getComputedStyleAsync(element, 'opacity')).toBe('0');
expect(contextSpy).toHaveBeenCalledTimes(3);
expect(contextSpy).toHaveBeenCalledWith({text: 'two', counter: 999});
done();
});
expect(contextSpy).toHaveBeenCalledWith({text: 'two', counter: 2});

expect(window.getComputedStyle(element).opacity).toBe('1');
});

it(`should complete and release the DOM element even if transition end is not fired`, (done) => {
element.classList.add('ngb-test-fade');

Expand Down
130 changes: 71 additions & 59 deletions src/util/transition/ngbTransition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,74 +3,86 @@ import {endWith, takeUntil} from 'rxjs/operators';
import {getTransitionDurationMs} from './util';
import {environment} from '../../environment';

export type NgbTransitionStartFn = (element: HTMLElement) => NgbTransitionEndFn | void;
export type NgbTransitionStartFn<T = any> = (element: HTMLElement, context: T) => NgbTransitionEndFn | void;
export type NgbTransitionEndFn = () => void;

export interface NgbTransitionOptions {
export interface NgbTransitionOptions<T> {
animation: boolean;
runningTransition: 'continue' | 'stop';
context?: T;
}

export interface NgbTransitionCtx<T> {
transition$: Subject<any>;
context: T;
}

const noopFn: NgbTransitionEndFn = () => {};

const {transitionTimerDelayMs} = environment;
const runningTransitions = new Map<HTMLElement, Subject<any>>();
const runningTransitions = new Map<HTMLElement, NgbTransitionCtx<any>>();

export const ngbRunTransition =
(element: HTMLElement, startFn: NgbTransitionStartFn, options: NgbTransitionOptions): Observable<undefined> => {

// If animations are disabled, we have to emit a value and complete the observable
if (!options.animation) {
return of(undefined);
}

// Checking if there are already running transitions on the given element.
const runningTransition$ = runningTransitions.get(element);
if (runningTransition$) {
switch (options.runningTransition) {
// If there is one running and we want for it to 'continue' to run, we have to cancel the new one.
// We're not emitting any values, but simply completing the observable (EMPTY).
case 'continue':
return EMPTY;
// If there is one running and we want for it to 'stop', we have to complete the running one.
// We're simply completing the running one and not emitting any values.
case 'stop':
runningTransition$.complete();
<T>(element: HTMLElement, startFn: NgbTransitionStartFn<T>, options: NgbTransitionOptions<T>):
Observable<undefined> => {

// If animations are disabled, we have to emit a value and complete the observable
if (!options.animation) {
return of(undefined);
}

// Getting initial context from options
let context = options.context || <T>{};

// Checking if there are already running transitions on the given element.
const running = runningTransitions.get(element);
if (running) {
switch (options.runningTransition) {
// If there is one running and we want for it to 'continue' to run, we have to cancel the new one.
// We're not emitting any values, but simply completing the observable (EMPTY).
case 'continue':
return EMPTY;
// If there is one running and we want for it to 'stop', we have to complete the running one.
// We're simply completing the running one and not emitting any values and merging newly provided context
// with the one coming from currently running transition.
case 'stop':
running.transition$.complete();
context = Object.assign(running.context, context);
runningTransitions.delete(element);
}
}

// If 'prefer-reduced-motion' is enabled, the 'transition' will be set to 'none'.
// In this case we have to call the start function, but can finish immediately by emitting a value,
// completing the observable and executing both start and end functions synchronously.
const {transitionProperty} = window.getComputedStyle(element);
if (transitionProperty === 'none') {
(startFn(element, context) || noopFn)();
return of(undefined);
}

// Starting a new transition
const transition$ = new Subject<any>();
const stop$ = transition$.pipe(endWith(true));
runningTransitions.set(element, {transition$, context});

const endFn = startFn(element, context) || noopFn;

const transitionDurationMs = getTransitionDurationMs(element);

// We have to both listen for the 'transitionend' event and have a 'just-in-case' timer,
// because 'transitionend' event might not be fired in some browsers, if the transitioning
// element becomes invisible (ex. when scrolling, making browser tab inactive, etc.). The timer
// guarantees, that we'll release the DOM element and complete 'ngbRunTransition'.
const transitionEnd$ = fromEvent(element, 'transitionend').pipe(takeUntil(stop$));
const timer$ = timer(transitionDurationMs + transitionTimerDelayMs).pipe(takeUntil(stop$));

race(timer$, transitionEnd$).pipe(takeUntil(stop$)).subscribe(() => {
runningTransitions.delete(element);
}
}

// If 'prefer-reduced-motion' is enabled, the 'transition' will be set to 'none'.
// In this case we have to call the start function, but can finish immediately by emitting a value,
// completing the observable and executing both start and end functions synchronously.
const {transitionProperty} = window.getComputedStyle(element);
if (transitionProperty === 'none') {
(startFn(element) || noopFn)();
return of(undefined);
}

// Starting a new transition
const transition$ = new Subject<any>();
const stop$ = transition$.pipe(endWith(true));
runningTransitions.set(element, transition$);

const endFn = startFn(element) || noopFn;

const transitionDurationMs = getTransitionDurationMs(element);

// We have to both listen for the 'transitionend' event and have a 'just-in-case' timer,
// because 'transitionend' event might not be fired in some browsers, if the transitioning
// element becomes invisible (ex. when scrolling, making browser tab inactive, etc.). The timer
// guarantees, that we'll release the DOM element and complete 'ngbRunTransition'.
const transitionEnd$ = fromEvent(element, 'transitionend').pipe(takeUntil(stop$));
const timer$ = timer(transitionDurationMs + transitionTimerDelayMs).pipe(takeUntil(stop$));

race(timer$, transitionEnd$).pipe(takeUntil(stop$)).subscribe(() => {
runningTransitions.delete(element);
endFn();
transition$.next();
transition$.complete();
});

return transition$.asObservable();
};
endFn();
transition$.next();
transition$.complete();
});

return transition$.asObservable();
};

0 comments on commit 12d753b

Please sign in to comment.