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, + }; +}