|
| 1 | +/** |
| 2 | + * @license |
| 3 | + * Copyright (c) 2025 - 2026 Vaadin Ltd. |
| 4 | + * This program is available under Apache License Version 2.0, available at https://vaadin.com/license/ |
| 5 | + */ |
| 6 | + |
| 7 | +const ANIMATION_ID = 'vaadin-master-detail-layout'; |
| 8 | + |
| 9 | +/** |
| 10 | + * Reads CSS custom properties from the element that control |
| 11 | + * animation keyframes and timing. |
| 12 | + * |
| 13 | + * @param {HTMLElement} element |
| 14 | + * @return {{ offset: string, easing: string, duration: number }} |
| 15 | + */ |
| 16 | +function getAnimationParams(element) { |
| 17 | + const computedStyle = getComputedStyle(element); |
| 18 | + const offset = computedStyle.getPropertyValue('--_transition-offset'); |
| 19 | + const easing = computedStyle.getPropertyValue('--_transition-easing'); |
| 20 | + const durationStr = computedStyle.getPropertyValue('--_transition-duration'); |
| 21 | + const duration = durationStr.endsWith('ms') ? parseFloat(durationStr) : parseFloat(durationStr) * 1000; |
| 22 | + return { offset, easing, duration }; |
| 23 | +} |
| 24 | + |
| 25 | +/** |
| 26 | + * Returns the currently running master-detail-layout animation on the |
| 27 | + * element, if any. Matches by the shared animation ID and `'running'` |
| 28 | + * play state. |
| 29 | + * |
| 30 | + * @param {HTMLElement} element |
| 31 | + * @return {Animation | undefined} |
| 32 | + */ |
| 33 | +export function getCurrentAnimation(element) { |
| 34 | + return element |
| 35 | + .getAnimations() |
| 36 | + .find((animation) => animation.id === ANIMATION_ID && animation.playState !== 'finished'); |
| 37 | +} |
| 38 | + |
| 39 | +/** |
| 40 | + * Returns the overall progress (0–1) of the current animation on the |
| 41 | + * element, computed as `currentTime / duration`. Returns 0 when no |
| 42 | + * animation is running. |
| 43 | + * |
| 44 | + * @param {HTMLElement} element |
| 45 | + * @return {number} |
| 46 | + */ |
| 47 | +export function getCurrentAnimationProgress(element) { |
| 48 | + const animation = getCurrentAnimation(element); |
| 49 | + if (!animation) { |
| 50 | + return 0; |
| 51 | + } |
| 52 | + const currentTime = animation.currentTime; |
| 53 | + if (currentTime == null) { |
| 54 | + return 0; |
| 55 | + } |
| 56 | + return currentTime / animation.effect.getTiming().duration; |
| 57 | +} |
| 58 | + |
| 59 | +/** |
| 60 | + * Animates the element using the Web Animations API. Cancels any |
| 61 | + * previous animation and resumes from the given progress for a |
| 62 | + * smooth handoff. No-op when CSS params are missing or progress is 1. |
| 63 | + * |
| 64 | + * @param {HTMLElement} element |
| 65 | + * @param {'in' | 'out'} direction |
| 66 | + * @param {Array<'fade' | 'slide'>} effects |
| 67 | + * @param {number} progress starting progress (0–1) for interrupted resumption |
| 68 | + * @return {Promise<void>} resolves when the animation finishes |
| 69 | + */ |
| 70 | +function animate(element, direction, effects, progress) { |
| 71 | + const { offset, easing, duration } = getAnimationParams(element); |
| 72 | + if (!offset || !duration || progress === 1) { |
| 73 | + return Promise.resolve(); |
| 74 | + } |
| 75 | + |
| 76 | + const oldAnimation = getCurrentAnimation(element); |
| 77 | + if (oldAnimation) { |
| 78 | + oldAnimation.cancel(); |
| 79 | + } |
| 80 | + |
| 81 | + const keyframes = {}; |
| 82 | + if (effects.includes('fade')) { |
| 83 | + keyframes.opacity = [0, 1]; |
| 84 | + } |
| 85 | + if (effects.includes('slide')) { |
| 86 | + keyframes.translate = [offset, 0]; |
| 87 | + } |
| 88 | + |
| 89 | + const newAnimation = element.animate(keyframes, { id: ANIMATION_ID, easing, duration }); |
| 90 | + newAnimation.pause(); |
| 91 | + newAnimation.currentTime = duration * progress; |
| 92 | + newAnimation.playbackRate = direction === 'in' ? 1 : -1; |
| 93 | + newAnimation.play(); |
| 94 | + return newAnimation.finished; |
| 95 | +} |
| 96 | + |
| 97 | +/** |
| 98 | + * Runs an enter animation on the element. |
| 99 | + * |
| 100 | + * @param {HTMLElement} element |
| 101 | + * @param {Array<'fade' | 'slide'>} effects |
| 102 | + * @param {number} progress starting progress (0–1) for interrupted resumption |
| 103 | + * @return {Promise<void>} resolves when the animation finishes |
| 104 | + */ |
| 105 | +export function animateIn(element, effects, progress) { |
| 106 | + return animate(element, 'in', effects, progress); |
| 107 | +} |
| 108 | + |
| 109 | +/** |
| 110 | + * Runs an exit animation on the element. |
| 111 | + * |
| 112 | + * @param {HTMLElement} element |
| 113 | + * @param {Array<'fade' | 'slide'>} effects |
| 114 | + * @param {number} progress starting progress (0–1) for interrupted resumption |
| 115 | + * @return {Promise<void>} resolves when the animation finishes |
| 116 | + */ |
| 117 | +export function animateOut(element, effects, progress) { |
| 118 | + return animate(element, 'out', effects, progress); |
| 119 | +} |
| 120 | + |
| 121 | +/** |
| 122 | + * Cancels all running animations on the element that match the shared animation ID. |
| 123 | + * |
| 124 | + * @param {HTMLElement} element |
| 125 | + */ |
| 126 | +export function cancelAnimations(element) { |
| 127 | + element.getAnimations({ subtree: true }).forEach((animation) => { |
| 128 | + if (animation.id === ANIMATION_ID) { |
| 129 | + animation.cancel(); |
| 130 | + } |
| 131 | + }); |
| 132 | +} |
| 133 | + |
| 134 | +/** |
| 135 | + * Parses a computed `gridTemplateColumns` / `gridTemplateRows` value |
| 136 | + * into an array of track sizes in pixels. Line names (e.g. `[name]`) |
| 137 | + * are stripped before parsing. |
| 138 | + * |
| 139 | + * @param {string} gridTemplate computed grid template string (e.g. `"200px [gap] 10px 400px"`) |
| 140 | + * @return {number[]} track sizes in pixels |
| 141 | + */ |
| 142 | +export function parseTrackSizes(gridTemplate) { |
| 143 | + return gridTemplate |
| 144 | + .replace(/\[[^\]]+\]/gu, '') |
| 145 | + .replace(/\s+/gu, ' ') |
| 146 | + .trim() |
| 147 | + .split(' ') |
| 148 | + .map(parseFloat); |
| 149 | +} |
| 150 | + |
| 151 | +/** |
| 152 | + * Determines whether the detail area overflows the host element, |
| 153 | + * meaning it should be shown as an overlay instead of side-by-side. |
| 154 | + * |
| 155 | + * Returns `false` when all tracks fit within the host, or when the |
| 156 | + * master's extra space (flexible portion) is large enough to absorb |
| 157 | + * the detail column. |
| 158 | + * |
| 159 | + * @param {number} hostSize the host element's width or height in pixels |
| 160 | + * @param {number[]} trackSizes [masterSize, masterExtra, detailSize] in pixels |
| 161 | + * @return {boolean} `true` if the detail overflows and should be overlaid |
| 162 | + */ |
| 163 | +export function detectOverflow(hostSize, trackSizes) { |
| 164 | + const [masterSize, masterExtra, detailSize] = trackSizes; |
| 165 | + |
| 166 | + if (Math.floor(masterSize + masterExtra + detailSize) <= Math.floor(hostSize)) { |
| 167 | + return false; |
| 168 | + } |
| 169 | + if (Math.floor(masterExtra) >= Math.floor(detailSize)) { |
| 170 | + return false; |
| 171 | + } |
| 172 | + return true; |
| 173 | +} |
0 commit comments