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

@@ -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>;