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:
@@ -10,6 +10,7 @@
|
||||
@hide="isExpanded = false"
|
||||
/>
|
||||
</div>
|
||||
<ExpandCollapseTransition>
|
||||
<div
|
||||
v-if="docs && docs.length > 0 && isExpanded"
|
||||
class="docs"
|
||||
@@ -27,11 +28,13 @@
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
</ExpandCollapseTransition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, ref, PropType } from 'vue';
|
||||
import ExpandCollapseTransition from '@/presentation/components/Shared/ExpandCollapse/ExpandCollapseTransition.vue';
|
||||
import DocumentationText from './DocumentationText.vue';
|
||||
import ToggleDocumentationButton from './ToggleDocumentationButton.vue';
|
||||
|
||||
@@ -39,6 +42,7 @@ export default defineComponent({
|
||||
components: {
|
||||
DocumentationText,
|
||||
ToggleDocumentationButton,
|
||||
ExpandCollapseTransition,
|
||||
},
|
||||
props: {
|
||||
docs: {
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
</LeafTreeNode>
|
||||
</div>
|
||||
</InteractableNode>
|
||||
<transition name="children-transition">
|
||||
<ExpandCollapseTransition>
|
||||
<ul
|
||||
v-if="hasChildren && isExpanded"
|
||||
class="children"
|
||||
@@ -44,12 +44,13 @@
|
||||
</template>
|
||||
</HierarchicalTreeNode>
|
||||
</ul>
|
||||
</transition>
|
||||
</ExpandCollapseTransition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, computed, toRef } from 'vue';
|
||||
import ExpandCollapseTransition from '@/presentation/components/Shared/ExpandCollapse/ExpandCollapseTransition.vue';
|
||||
import { TreeRoot } from '../TreeRoot/TreeRoot';
|
||||
import { useCurrentTreeNodes } from '../UseCurrentTreeNodes';
|
||||
import { NodeRenderingStrategy } from '../Rendering/Scheduling/NodeRenderingStrategy';
|
||||
@@ -64,6 +65,7 @@ export default defineComponent({
|
||||
components: {
|
||||
LeafTreeNode,
|
||||
InteractableNode,
|
||||
ExpandCollapseTransition,
|
||||
},
|
||||
props: {
|
||||
nodeId: {
|
||||
@@ -178,19 +180,4 @@ export default defineComponent({
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@mixin left-fade-transition($name) {
|
||||
.#{$name}-enter-active,
|
||||
.#{$name}-leave-active {
|
||||
transition: opacity .3s, transform .3s;
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
.#{$name}-enter-from,
|
||||
.#{$name}-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateX(-2em);
|
||||
}
|
||||
}
|
||||
@include left-fade-transition('children-transition');
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
<template>
|
||||
<transition
|
||||
@enter="onTransitionEnter"
|
||||
@leave="onTransitionLeave"
|
||||
>
|
||||
<slot />
|
||||
</transition>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from 'vue';
|
||||
import { useExpandCollapseAnimation } from './UseExpandCollapseAnimation';
|
||||
|
||||
export default defineComponent({
|
||||
setup() {
|
||||
const { collapse, expand } = useExpandCollapseAnimation();
|
||||
|
||||
async function onTransitionEnter(element: Element, done: () => void) {
|
||||
await collapse(element);
|
||||
done();
|
||||
}
|
||||
|
||||
async function onTransitionLeave(element: Element, done: () => void) {
|
||||
await expand(element);
|
||||
done();
|
||||
}
|
||||
|
||||
return {
|
||||
onTransitionEnter,
|
||||
onTransitionLeave,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
./UseExpandCollapseAnimation
|
||||
@@ -0,0 +1,223 @@
|
||||
import { PlatformTimer } from '@/application/Common/Timing/PlatformTimer';
|
||||
import { Timer } from '@/application/Common/Timing/Timer';
|
||||
|
||||
export type AnimationFunction = (element: Element) => Promise<void>;
|
||||
|
||||
export function useExpandCollapseAnimation(
|
||||
timer: Timer = PlatformTimer,
|
||||
): {
|
||||
readonly collapse: AnimationFunction;
|
||||
readonly expand: AnimationFunction;
|
||||
} {
|
||||
return {
|
||||
collapse: (element: Element) => animateCollapse(element, timer),
|
||||
expand: (element: Element) => animateExpand(element, timer),
|
||||
};
|
||||
}
|
||||
|
||||
function animateCollapse(
|
||||
element: Element,
|
||||
timer: Timer,
|
||||
): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
assertElementIsHTMLElement(element);
|
||||
applyStyleMutations(element, (elementStyle) => {
|
||||
const measuredStyles = captureTransitionDimensions(element, elementStyle);
|
||||
setTransitionPropertiesToHidden(elementStyle);
|
||||
hideOverflow(elementStyle);
|
||||
triggerElementRepaint(element);
|
||||
setTransitionPropertiesToElementDimensions(elementStyle, measuredStyles);
|
||||
startTransition(elementStyle, timer).then(() => {
|
||||
elementStyle.restoreOriginalStyles();
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function animateExpand(
|
||||
element: Element,
|
||||
timer: Timer,
|
||||
): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
assertElementIsHTMLElement(element);
|
||||
applyStyleMutations(element, (elementStyle) => {
|
||||
const measuredStyles = captureTransitionDimensions(element, elementStyle);
|
||||
setTransitionPropertiesToElementDimensions(elementStyle, measuredStyles);
|
||||
hideOverflow(elementStyle);
|
||||
triggerElementRepaint(element);
|
||||
setTransitionPropertiesToHidden(elementStyle);
|
||||
startTransition(elementStyle, timer).then(() => {
|
||||
elementStyle.restoreOriginalStyles();
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export const TRANSITION_DURATION_MILLISECONDS = 300;
|
||||
|
||||
const TRANSITION_EASING_FUNCTION = 'ease-in-out';
|
||||
|
||||
function startTransition(
|
||||
styleMutator: ElementStyleMutator,
|
||||
timer: Timer,
|
||||
): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
styleMutator.changeStyle('transition', createTransitionStyleValue());
|
||||
timer.setTimeout(() => resolve(), TRANSITION_DURATION_MILLISECONDS);
|
||||
});
|
||||
}
|
||||
|
||||
interface ElementStyleMutator {
|
||||
readonly restoreOriginalStyles: () => void;
|
||||
readonly changeStyle: (key: MutatedStyleProperty, value: string) => void;
|
||||
}
|
||||
|
||||
function applyStyleMutations(
|
||||
element: HTMLElement,
|
||||
mutator: (elementStyle: ElementStyleMutator) => void,
|
||||
) {
|
||||
const originalStyles = getOriginalStyles(element);
|
||||
const changeStyle = (key: MutatedStyleProperty, value: string) => {
|
||||
element.style[key] = value;
|
||||
};
|
||||
mutator({
|
||||
restoreOriginalStyles: () => restoreOriginalStyles(element, originalStyles),
|
||||
changeStyle,
|
||||
});
|
||||
}
|
||||
|
||||
function setTransitionPropertiesToHidden(elementStyle: ElementStyleMutator): void {
|
||||
TransitionedStyleProperties.forEach((key) => {
|
||||
elementStyle.changeStyle(key, '0px');
|
||||
});
|
||||
}
|
||||
|
||||
function setTransitionPropertiesToElementDimensions(
|
||||
elementStyle: ElementStyleMutator,
|
||||
elementDimensions: TransitionStyleRecords,
|
||||
): void {
|
||||
Object.entries(elementDimensions).forEach(([key, value]) => {
|
||||
elementStyle.changeStyle(key as AnimatedStyleProperty, value);
|
||||
});
|
||||
}
|
||||
|
||||
function hideOverflow(elementStyle: ElementStyleMutator): void {
|
||||
elementStyle.changeStyle('overflow', 'hidden');
|
||||
}
|
||||
|
||||
function createTransitionStyleValue(): string {
|
||||
const transitions = new Array<string>();
|
||||
TransitionedStyleProperties.forEach((key) => {
|
||||
transitions.push(`${getCssStyleName(key)} ${TRANSITION_DURATION_MILLISECONDS}ms ${TRANSITION_EASING_FUNCTION}`);
|
||||
});
|
||||
return transitions.join(', ');
|
||||
}
|
||||
|
||||
function captureTransitionDimensions(
|
||||
element: Readonly<HTMLElement>,
|
||||
styleMutator: ElementStyleMutator,
|
||||
): TransitionStyleRecords {
|
||||
let styles: TransitionStyleRecords | undefined;
|
||||
executeActionWithTemporaryVisibility(
|
||||
element.style,
|
||||
styleMutator,
|
||||
() => {
|
||||
styles = {
|
||||
height: `${element.offsetHeight}px`,
|
||||
paddingTop: element.style.paddingTop || getElementComputedStylePropertyValue(element, 'padding-top'),
|
||||
paddingBottom: element.style.paddingBottom || getElementComputedStylePropertyValue(element, 'padding-bottom'),
|
||||
};
|
||||
},
|
||||
);
|
||||
return styles as TransitionStyleRecords;
|
||||
}
|
||||
|
||||
function triggerElementRepaint(
|
||||
element: Readonly<HTMLElement>,
|
||||
): void {
|
||||
element.offsetHeight; // eslint-disable-line @typescript-eslint/no-unused-expressions
|
||||
}
|
||||
|
||||
function getElementComputedStylePropertyValue(element: Element, style: string) {
|
||||
return getComputedStyle(element, null).getPropertyValue(style);
|
||||
}
|
||||
|
||||
function executeActionWithTemporaryVisibility(
|
||||
readableStyle: Readonly<CSSStyleDeclaration>,
|
||||
styleMutator: ElementStyleMutator,
|
||||
actionWhileRendered: () => void,
|
||||
) {
|
||||
const {
|
||||
visibility: initialVisibility,
|
||||
display: initialDisplay,
|
||||
} = readableStyle;
|
||||
styleMutator.changeStyle('visibility', 'hidden');
|
||||
styleMutator.changeStyle('display', '');
|
||||
try {
|
||||
actionWhileRendered();
|
||||
} finally {
|
||||
styleMutator.changeStyle('visibility', initialVisibility);
|
||||
styleMutator.changeStyle('display', initialDisplay);
|
||||
}
|
||||
}
|
||||
|
||||
function getOriginalStyles(element: HTMLElement): MutatedStyleProperties {
|
||||
const records = {} as MutatedStyleProperties;
|
||||
MutatedStylePropertiesDuringAnimation.forEach((key) => {
|
||||
records[key] = element.style[key];
|
||||
});
|
||||
return records;
|
||||
}
|
||||
|
||||
function restoreOriginalStyles(
|
||||
element: HTMLElement,
|
||||
originalStyles: MutatedStyleProperties,
|
||||
): void {
|
||||
Object.entries(originalStyles).forEach(([key, value]) => {
|
||||
element.style[key as MutatedStyleProperty] = value;
|
||||
});
|
||||
}
|
||||
|
||||
function getCssStyleName(style: AnimatedStyleProperty): string {
|
||||
const cssPropertyNames: TransitionStyleRecords = {
|
||||
height: 'height',
|
||||
paddingTop: 'padding-top',
|
||||
paddingBottom: 'padding-bottom',
|
||||
};
|
||||
return cssPropertyNames[style];
|
||||
}
|
||||
|
||||
function assertElementIsHTMLElement(
|
||||
element: Element,
|
||||
): asserts element is HTMLElement {
|
||||
if (!element) {
|
||||
throw new Error('Element was not found');
|
||||
}
|
||||
if (!(element instanceof HTMLElement)) {
|
||||
throw new Error('Element is not an HTMLElement');
|
||||
}
|
||||
}
|
||||
|
||||
const TransitionedStyleProperties = [
|
||||
'height',
|
||||
'paddingTop',
|
||||
'paddingBottom',
|
||||
] as const;
|
||||
|
||||
const MutatedStylePropertiesDuringAnimation = [
|
||||
...TransitionedStyleProperties,
|
||||
'transition',
|
||||
'overflow',
|
||||
'visibility',
|
||||
'display',
|
||||
] as const;
|
||||
|
||||
type MutatedStyleProperty = typeof MutatedStylePropertiesDuringAnimation[number];
|
||||
|
||||
export type MutatedStyleProperties = Record<MutatedStyleProperty, string>;
|
||||
|
||||
type AnimatedStyleProperty = typeof TransitionedStyleProperties[number];
|
||||
|
||||
type TransitionStyleRecords = Record<AnimatedStyleProperty, string>;
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
39
tests/unit/shared/PromiseInspection.ts
Normal file
39
tests/unit/shared/PromiseInspection.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user