Add UI animations for expand/collapse actions

This commit improves the user experience by adding smooth transitions
for expanding and collapsing tree node items and documentation sections.
The introduction of these animations makes the interface feel more
dynamic and responsive to user interactions.

Key changes:

- Implement a new `ExpandCollapseTransition` component to wrap UI
  elements requiring expand/collapse animations.
- Utiliz the `ExpandCollapseTransition` in tree view nodes and
  documentation sections to animate visibility changes.
- Refactor CSS to remove obsolete transition mixins, leveraging Vue's
  transition system for consistency and maintainability.
This commit is contained in:
undergroundwires
2024-02-18 22:38:32 +01:00
parent faa7a38a7d
commit fb08f03765
7 changed files with 417 additions and 57 deletions

View File

@@ -1,5 +1,6 @@
import { describe, it, expect } from 'vitest';
import { sleep, SchedulerType, SchedulerCallbackType } from '@/infrastructure/Threading/AsyncSleep';
import { flushPromiseResolutionQueue, watchPromiseState } from '@tests/unit/shared/PromiseInspection';
describe('AsyncSleep', () => {
describe('sleep', () => {
@@ -32,10 +33,6 @@ describe('AsyncSleep', () => {
});
});
function flushPromiseResolutionQueue() {
return Promise.resolve();
}
class SchedulerMock {
public readonly mock: SchedulerType;
@@ -59,24 +56,3 @@ class SchedulerMock {
this.scheduledActions = this.scheduledActions.filter((action) => !dueActions.includes(action));
}
}
function watchPromiseState<T>(promise: Promise<T>) {
let isPending = true;
let isRejected = false;
let isFulfilled = false;
promise.then(
() => {
isFulfilled = true;
isPending = false;
},
() => {
isRejected = true;
isPending = false;
},
);
return {
isFulfilled: () => isFulfilled,
isPending: () => isPending,
isRejected: () => isRejected,
};
}

View File

@@ -0,0 +1,96 @@
import { describe, it, expect } from 'vitest';
import {
AnimationFunction, TRANSITION_DURATION_MILLISECONDS,
useExpandCollapseAnimation, MutatedStyleProperties,
} from '@/presentation/components/Shared/ExpandCollapse/UseExpandCollapseAnimation';
import { TimerStub } from '@tests/unit/shared/Stubs/TimerStub';
import { watchPromiseState, flushPromiseResolutionQueue } from '@tests/unit/shared/PromiseInspection';
import { formatAssertionMessage } from '@tests/shared/FormatAssertionMessage';
describe('UseExpandCollapseAnimation', () => {
describe('useExpandCollapseAnimation', () => {
describe('collapse', () => {
describe('animations', () => {
runSharedTestsForAnimation((hook) => hook.collapse);
});
});
describe('expand', () => {
describe('animations', () => {
runSharedTestsForAnimation((hook) => hook.expand);
});
});
});
});
function runSharedTestsForAnimation(
getAnimator: (hookResult: ReturnType<typeof useExpandCollapseAnimation>) => AnimationFunction,
) {
it('completes after transition duration', async () => {
// arrange
const timer = new TimerStub();
const element = createElementMock();
const passedDurationInMs = TRANSITION_DURATION_MILLISECONDS;
const hookResult = useExpandCollapseAnimation(timer);
const animator = getAnimator(hookResult);
// act
const promiseState = watchPromiseState(animator(element));
timer.tickNext(passedDurationInMs);
await flushPromiseResolutionQueue();
// assert
expect(promiseState.isPending()).to.equal(true);
});
it('remains unresolved before transition duration', async () => {
// arrange
const timer = new TimerStub();
const element = createElementMock();
const passedDurationInMs = TRANSITION_DURATION_MILLISECONDS / 2;
const hookResult = useExpandCollapseAnimation(timer);
const animator = getAnimator(hookResult);
// act
const promiseState = watchPromiseState(animator(element));
timer.tickNext(passedDurationInMs);
await flushPromiseResolutionQueue();
// assert
expect(promiseState.isFulfilled()).to.equal(false);
});
it('restores original styles post-animation', async () => {
// arrange
const expectedStyleValues: MutatedStyleProperties = {
height: 'auto',
overflow: 'scroll',
paddingBottom: '5px',
paddingTop: '5px',
transition: 'all',
visibility: 'visible',
display: 'inline-block',
};
const element = document.createElement('div');
Object.entries(expectedStyleValues).forEach(([key, value]) => {
element.style[key] = value;
});
const timer = new TimerStub();
const hookResult = useExpandCollapseAnimation(timer);
const animator = getAnimator(hookResult);
// act
const promise = animator(element);
timer.tickNext(TRANSITION_DURATION_MILLISECONDS);
await promise;
// assert
Object.entries(expectedStyleValues).forEach(([key, expectedStyleValue]) => {
const actualStyleValue = element.style[key];
expect(actualStyleValue).to.equal(expectedStyleValue, formatAssertionMessage([
`Style key: ${key}`,
`Expected style value: ${expectedStyleValue}`,
`Actual style value: ${actualStyleValue}`,
`Initial style value: ${expectedStyleValues}`,
'All styles:',
...Object.entries(expectedStyleValues)
.map(([k, value]) => `\t- ${k} > actual: "${element.style[k]}" | expected: "${value}"`),
]));
});
});
}
function createElementMock(): HTMLElement {
return document.createElement('div');
}

View File

@@ -0,0 +1,39 @@
/**
* Ensures all promises scheduled to resolve in the microtask queue are resolved.
* This function is designed to be used in tests to wait for promises to settle
* before proceeding with assertions. It works by returning a promise that resolves
* on the next tick of the event loop, allowing any pending promise resolutions to
* complete.
*/
export function flushPromiseResolutionQueue() {
return Promise.resolve();
}
/**
* Monitors the state of a promise, providing a way to query whether it is
* fulfilled, pending, or rejected. This utility is particularly useful in tests
* to ascertain the current state of a promise at any given moment without
* interfering with its natural lifecycle. It encapsulates the promise in a
* non-intrusive manner, allowing tests to synchronously check the promise's
* status post certain asynchronous operations.
*/
export function watchPromiseState<T>(promise: Promise<T>) {
let isPending = true;
let isRejected = false;
let isFulfilled = false;
promise.then(
() => {
isFulfilled = true;
isPending = false;
},
() => {
isRejected = true;
isPending = false;
},
);
return {
isFulfilled: () => isFulfilled,
isPending: () => isPending,
isRejected: () => isRejected,
};
}