Skip to content

Commit 2152106

Browse files
authored
refactor: rewrite MDL animation logic for more linear flow (#11437)
1 parent 12ce851 commit 2152106

5 files changed

Lines changed: 325 additions & 381 deletions

File tree

packages/master-detail-layout/ARCHITECTURE.md

Lines changed: 14 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -143,58 +143,44 @@ Detail panel transitions use the Web Animations API (`element.animate()`) on `tr
143143

144144
Animation parameters are driven by CSS custom properties, read once per transition to avoid repeated layout reads:
145145

146-
- `--_detail-offscreen` — off-screen translate value. Defaults to `30px` (subtle slide in split mode), overridden to `calc((100% + 30px))` in overlay mode (full panel slide). Vertical orientation uses the Y axis.
146+
- `--_transition-offset` — off-screen translate value. Defaults to `30px` (subtle slide in split mode), overridden to `calc((100% + 30px))` in overlay mode (full panel slide). Vertical orientation uses the Y axis.
147147
- `--_transition-duration` — defaults to `0s`, enabled via `@media (prefers-reduced-motion: no-preference)`: 200ms for split mode, 300ms for overlay mode. Replace transitions in split mode use 0ms (no slide, just instant swap).
148148
- `--_transition-easing` — cubic-bezier easing
149149

150150
CSS handles resting states via `translate` and `opacity` on `#detail`: offscreen and transparent by default, on-screen and opaque when `has-detail` is set. RTL is supported via `--_rtl-multiplier`.
151151

152152
### Transition types
153153

154-
- **Add**: DOM is updated first (new element inserted, `has-detail` set), then the detail slides in from off-screen. In split mode, also fades from opacity 0 → 1.
155-
- **Remove**: the detail slides out to off-screen first (in split mode, also fades to opacity 0), then the DOM is updated (element removed, `has-detail` cleared) on `animation.finished`. The `fill: 'forwards'` on the animation keeps the detail offscreen between animation end and the deferred layout recalculation (see below).
156-
- **Replace** (overlay): old content is reassigned to `slot="detail-outgoing"` (stays in light DOM so styles continue to apply), then old slides out while new slides in simultaneously
154+
- **Add**: DOM is updated first (new element inserted, `has-detail` set), then the detail fades and slides in from off-screen.
155+
- **Remove**: the detail fades and slides out to off-screen first, then the DOM is updated (element removed, `has-detail` cleared) on `animation.finished`.
156+
- **Replace** (overlay): old content is reassigned to `slot="detail-outgoing"` (stays in light DOM so styles continue to apply), then old fades/slides out while new fades/slides in simultaneously.
157157
- **Replace** (split): 0ms duration (instant swap). Old content moves to outgoing slot, new content appears immediately.
158158

159159
The `noAnimation` property (reflected as `no-animation` attribute) skips all animations. Animations are also disabled when `--_transition-duration` resolves to `0s`.
160160

161161
### Backdrop fade
162162

163-
The backdrop uses `opacity: 0/1` + `pointer-events: none/auto` (not `display: none/block`) so it can be animated. A linear opacity fade runs in parallel with the detail slide for overlay add/remove transitions. During replace, the backdrop stays visible (no fade).
163+
The backdrop uses `opacity: 0/1` + `pointer-events: none/auto` (not `display: none/block`) so it can be animated. A linear opacity fade (via `--_transition-easing: linear` on `#backdrop`) runs in parallel with the detail slide for overlay add/remove transitions. During replace, the backdrop stays visible (no fade).
164164

165-
### Transition flow (`_startTransition`)
165+
### Transition flow
166166

167-
`_startTransition` is an async method. Each `await` is a yield point where interruption is possible — a version counter is checked after each `await` to bail if a newer transition has started.
167+
The transition orchestrator is an `async` method. Each transition type has its own handler with a linear top-to-bottom flow:
168168

169-
**Add/Replace flow:**
170-
1. Capture interrupted state, cancel previous, snapshot outgoing (replace only)
171-
2. Run update callback — DOM changes + `_finishTransition()` (queues `recalculateLayout` microtask)
172-
3. `await` microtask — Lit elements render, `recalculateLayout` sets `overlay`/`has-detail`
173-
4. Read animation params from CSS, start animations with `fill: 'forwards'`
174-
5. `await` animation completion
175-
6. `__endTransition()` — cancel animations (removes fill), clean up
169+
**Add:** update DOM → `await` microtask (Lit renders, layout recalculated) → animate detail in + backdrop fade in
176170

177-
**Remove flow:**
178-
1. Capture interrupted state, cancel previous
179-
2. Read animation params, start slide-out animation with `fill: 'forwards'`
180-
3. `await` animation completion
181-
4. Run update callback — DOM changes + `_finishTransition()` (queues `recalculateLayout` microtask)
182-
5. `await` microtask — `recalculateLayout` clears `has-detail`
183-
6. `__endTransition()` — cancel animations (removes fill), clean up
171+
**Remove:** animate detail out + backdrop fade out → update DOM → `await` microtask (layout recalculated)
184172

185-
### `fill: 'forwards'`
173+
**Replace:** snapshot outgoing → update DOM → `await` microtask (Lit renders, layout recalculated) → animate new detail in + old detail out simultaneously → clean up outgoing
186174

187-
All animations use `fill: 'forwards'` to keep the final keyframe applied after the animation finishes. For remove, this bridges the gap between animation end (step 3) and layout recalculation (step 5) — without fill, the CSS resting state (`translate: none` from `has-detail`) would flash for one frame. The fill is cleaned up by `__endTransition()` in step 6, after `has-detail` is already cleared.
175+
The `transition` attribute is set at the start and cleared when the handler completes (or when interrupted). Cancelled animations throw `AbortError`, which is caught and silently ignored.
188176

189-
### `_finishTransition` and microtask deferral
177+
### DOM update and layout recalculation
190178

191-
`_finishTransition()` defers `recalculateLayout()` to a microtask so Lit elements can render before their intrinsic size is measured for auto-sizing. The `await Promise.resolve()` in `_startTransition` waits for this microtask to complete before reading animation params.
179+
The DOM update callback is `async` — it modifies the slot content, then yields a microtask (`await Promise.resolve()`) so Lit elements can render before `recalculateLayout()` measures their intrinsic size. This ensures the `[overlay]` attribute and CSS custom properties reflect the correct layout state before animation parameters are read.
192180

193181
### Smooth interruption
194182

195-
`animation.cancel()` removes the animation effect and the element reverts to its CSS resting state — causing a visual jump. To avoid this, the current `translate` and `opacity` values are read via `getComputedStyle()` _before_ cancelling. These captured mid-flight values become the starting keyframe of the new animation, so the panel changes direction and opacity smoothly from where it actually is.
196-
197-
For `replace` interruptions, the captured state is applied to the outgoing element (since the interrupted content moves from the detail slot to the outgoing slot).
183+
Each animation is tagged with a shared ID. When a new transition starts, the running animation is found via `getAnimations()` and its progress (0–1) is computed from `currentTime / duration`. The old animation is cancelled and the new one starts from the captured progress, using `playbackRate: -1` for reverse. This provides smooth direction changes without needing to read `getComputedStyle` or manage explicit keyframe captures.
198184

199185
### Z-index layering
200186

packages/master-detail-layout/src/styles/vaadin-master-detail-layout-base-styles.js

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,10 @@ export const masterDetailLayoutStyles = css`
1414
--_detail-extra: 0px;
1515
--_detail-cached-size: min-content;
1616
17+
--_rtl-multiplier: 1;
1718
--_transition-duration: 0s;
1819
--_transition-easing: cubic-bezier(0.78, 0, 0.22, 1);
19-
--_rtl-multiplier: 1;
20-
--_detail-offscreen: calc(30px * var(--_rtl-multiplier));
20+
--_transition-offset: calc(30px * var(--_rtl-multiplier));
2121
2222
display: grid;
2323
box-sizing: border-box;
@@ -42,7 +42,7 @@ export const masterDetailLayoutStyles = css`
4242
}
4343
4444
:host([orientation='vertical']) {
45-
--_detail-offscreen: 0 30px;
45+
--_transition-offset: 0 30px;
4646
4747
grid-template-columns: 100%;
4848
grid-template-rows:
@@ -87,6 +87,8 @@ export const masterDetailLayoutStyles = css`
8787
}
8888
8989
#backdrop {
90+
--_transition-easing: linear;
91+
9092
position: absolute;
9193
inset: 0;
9294
z-index: 2;
@@ -135,7 +137,7 @@ export const masterDetailLayoutStyles = css`
135137
136138
/* Detail transition: off-screen by default, on-screen when has-detail */
137139
#detail {
138-
translate: var(--_detail-offscreen);
140+
translate: var(--_transition-offset);
139141
opacity: 0;
140142
z-index: 4;
141143
}
@@ -146,11 +148,11 @@ export const masterDetailLayoutStyles = css`
146148
}
147149
148150
:host([overlay]) {
149-
--_detail-offscreen: calc((100% + 30px) * var(--_rtl-multiplier));
151+
--_transition-offset: calc((100% + 30px) * var(--_rtl-multiplier));
150152
}
151153
152154
:host([overlay][orientation='vertical']) {
153-
--_detail-offscreen: 0 calc(100% + 30px);
155+
--_transition-offset: 0 calc(100% + 30px);
154156
}
155157
156158
:host([has-detail][overlay]) :is(#detail, #outgoing) {
Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
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

Comments
 (0)