diff --git a/src/presentation/components/Scripts/View/Tree/NodeContent/Documentation/DocumentableNode.vue b/src/presentation/components/Scripts/View/Tree/NodeContent/Documentation/DocumentableNode.vue
index 638545af..9c75a9f9 100644
--- a/src/presentation/components/Scripts/View/Tree/NodeContent/Documentation/DocumentableNode.vue
+++ b/src/presentation/components/Scripts/View/Tree/NodeContent/Documentation/DocumentableNode.vue
@@ -10,28 +10,31 @@
@hide="isExpanded = false"
/>
-
+ >
+
+
+
+./UseExpandCollapseAnimation
diff --git a/src/presentation/components/Shared/ExpandCollapse/UseExpandCollapseAnimation.ts b/src/presentation/components/Shared/ExpandCollapse/UseExpandCollapseAnimation.ts
new file mode 100644
index 00000000..dc9a1927
--- /dev/null
+++ b/src/presentation/components/Shared/ExpandCollapse/UseExpandCollapseAnimation.ts
@@ -0,0 +1,223 @@
+import { PlatformTimer } from '@/application/Common/Timing/PlatformTimer';
+import { Timer } from '@/application/Common/Timing/Timer';
+
+export type AnimationFunction = (element: Element) => Promise;
+
+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 {
+ 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 {
+ 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 {
+ 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();
+ TransitionedStyleProperties.forEach((key) => {
+ transitions.push(`${getCssStyleName(key)} ${TRANSITION_DURATION_MILLISECONDS}ms ${TRANSITION_EASING_FUNCTION}`);
+ });
+ return transitions.join(', ');
+}
+
+function captureTransitionDimensions(
+ element: Readonly,
+ 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,
+): 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,
+ 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;
+
+type AnimatedStyleProperty = typeof TransitionedStyleProperties[number];
+
+type TransitionStyleRecords = Record;
diff --git a/tests/unit/infrastructure/Threading/AsyncSleep.spec.ts b/tests/unit/infrastructure/Threading/AsyncSleep.spec.ts
index 0e171e0c..811e0b4d 100644
--- a/tests/unit/infrastructure/Threading/AsyncSleep.spec.ts
+++ b/tests/unit/infrastructure/Threading/AsyncSleep.spec.ts
@@ -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(promise: Promise) {
- 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,
- };
-}
diff --git a/tests/unit/presentation/components/Shared/ExpandCollapse/UseExpandCollapseAnimation.spec.ts b/tests/unit/presentation/components/Shared/ExpandCollapse/UseExpandCollapseAnimation.spec.ts
new file mode 100644
index 00000000..88add4a8
--- /dev/null
+++ b/tests/unit/presentation/components/Shared/ExpandCollapse/UseExpandCollapseAnimation.spec.ts
@@ -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) => 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');
+}
diff --git a/tests/unit/shared/PromiseInspection.ts b/tests/unit/shared/PromiseInspection.ts
new file mode 100644
index 00000000..acdec341
--- /dev/null
+++ b/tests/unit/shared/PromiseInspection.ts
@@ -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(promise: Promise) {
+ 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,
+ };
+}