Skip to content

Commit 8a429e6

Browse files
web-padawanclaude
andcommitted
refactor: replace view transitions with Web Animations in MDL
Replace the View Transitions API with the Web Animations API for master-detail-layout detail panel animations. This fixes two issues: - View transition styles don't work when MDL is inside a shadow root - [overflow] attribute eagerly removed when closing details Uses element.animate() with parameters read from CSS custom properties (--_mdl-detail-offscreen, --_mdl-transition-duration, --_mdl-easing). Animations are interruptible via cancel(). Replace transitions show old content sliding out simultaneously with new content sliding in, using a detail-outgoing slot that keeps old content in light DOM. Removes SlotStylesMixin dependency and document-level pseudo-elements. Uses preventScroll on focus to avoid scrolling the overflow:hidden host during overlay transitions. Duration defaults to 0s and is enabled via prefers-reduced-motion: no-preference media query. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent e8e05fe commit 8a429e6

12 files changed

Lines changed: 745 additions & 438 deletions

packages/aura/src/components/dialog.css

Lines changed: 0 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -17,18 +17,8 @@ vaadin-confirm-dialog::part(overlay) {
1717
var(--vaadin-dialog-shadow, var(--vaadin-overlay-shadow, var(--aura-overlay-shadow)));
1818
--aura-surface-level: 2;
1919
--aura-surface-opacity: var(--aura-overlay-surface-opacity);
20-
21-
/* TODO probably should be in base styles */
22-
/* Keeps dialogs on top of MDL view transitions */
23-
view-transition-name: vaadin-dialog;
2420
}
2521

2622
vaadin-confirm-dialog::part(message) {
2723
color: var(--vaadin-text-color-secondary);
2824
}
29-
30-
/* TODO probably should be in base styles */
31-
::view-transition-group(vaadin-dialog) {
32-
border-radius: var(--vaadin-dialog-border-radius, var(--vaadin-radius-l));
33-
z-index: 1;
34-
}

packages/aura/src/components/master-detail-layout.css

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -30,11 +30,3 @@ vaadin-master-detail-layout:not([overflow])::part(detail) {
3030
border-start-end-radius: var(--_app-layout-radius);
3131
border-end-end-radius: var(--_app-layout-radius);
3232
}
33-
34-
/* TODO these end up affecting all MDLs, not just the one directly inside the App Layout */
35-
::view-transition-group(vaadin-mdl-backdrop),
36-
::view-transition-group(vaadin-mdl-master),
37-
::view-transition-group(vaadin-mdl-detail) {
38-
border-radius: var(--_app-layout-radius);
39-
overflow: hidden;
40-
}

packages/aura/src/components/notification.css

Lines changed: 0 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,6 @@ vaadin-notification-card::part(overlay) {
1313
--aura-surface-level: 3.5;
1414
background: var(--vaadin-notification-background, var(--aura-surface-color));
1515
box-shadow: var(--aura-overlay-outline-shadow), var(--vaadin-notification-shadow, var(--aura-overlay-shadow));
16-
17-
/* TODO probably should be in base styles */
18-
/* Keeps notifications on top of MDL view transitions */
19-
view-transition-name: vaadin-notification;
2016
}
2117

2218
vaadin-notification-card:is(
@@ -44,21 +40,6 @@ vaadin-notification-card:is(
4440
var(--vaadin-notification-shadow, var(--aura-shadow-m));
4541
}
4642

47-
::view-transition-group(vaadin-notification) {
48-
/* Keep on top of MDL view-transition elements */
49-
z-index: 1;
50-
/* The backdrop-filter from vaadin-notification-card::part(overlay) is copied here, so we need to clip it with the same border radius */
51-
border-radius: var(--vaadin-notification-border-radius, var(--vaadin-radius-l));
52-
}
53-
54-
/* In Safari, the backdrop-filter is copied to transition-group pseudo element but also retained in the new/old pseudo elements */
55-
/* Removing it from the transition-group makes it look better */
56-
@supports (background: -webkit-named-image(i)) {
57-
::view-transition-group(vaadin-notification) {
58-
backdrop-filter: none;
59-
}
60-
}
61-
6243
vaadin-notification-card vaadin-card {
6344
--vaadin-card-border-width: 0px;
6445
--vaadin-card-gap: var(--vaadin-gap-xs) var(--vaadin-gap-s);

packages/master-detail-layout/ARCHITECTURE.md

Lines changed: 40 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -62,10 +62,6 @@ Layout detection is split into two methods to avoid forced reflows:
6262
- ResizeObserver callback: calls `__computeLayoutState()` (read), cancels any pending rAF via `cancelAnimationFrame`, then defers `__applyLayoutState()` (write) via `requestAnimationFrame`. Cancelling ensures the write phase always uses the latest state when multiple callbacks fire per frame.
6363
- **Property observers** (`masterSize`/`detailSize`) only update CSS custom properties — ResizeObserver picks up the resulting size changes automatically
6464

65-
### View transitions
66-
67-
`_finishTransition()` uses `queueMicrotask` to call both `__computeLayoutState()` + `__applyLayoutState()` synchronously. The microtask runs before the Promise resolution propagates to `startViewTransition`, ensuring the "new" snapshot captures the correct overlay state (backdrop, absolute positioning). The `getComputedStyle` read in the microtask does cause a forced reflow, but this is unavoidable for correct transition snapshots.
68-
6965
## Overlay Modes
7066

7167
When `overflow` AND `has-detail` are both set, the detail becomes an overlay:
@@ -100,15 +96,48 @@ When no detail is present, master's extra track is set to `calc(100% - masterSiz
10096

10197
Set when detail first appears with overflow, cleared when detail is removed or overflow resolves.
10298

103-
## View Transitions
99+
## Detail Animations
100+
101+
Detail panel slide transitions use the Web Animations API (`element.animate()`) on the `translate` property. This works inside shadow roots (unlike the View Transitions API).
102+
103+
### CSS custom properties
104+
105+
Animation parameters are driven by CSS custom properties, read once per transition to avoid repeated layout reads:
106+
107+
- `--_detail-offscreen` — off-screen translate value (horizontal or vertical depending on orientation)
108+
- `--_transition-duration` — defaults to `0s`, enabled to `400ms` via `@media (prefers-reduced-motion: no-preference)` + `:host(:not([no-animation]))`
109+
- `--_transition-easing` — cubic-bezier easing
110+
111+
CSS handles resting states: `translate: var(--_detail-offscreen)` on `[part~='detail']` by default, overridden to `translate: none` by `:host([has-detail])`. RTL is supported via `--_rtl-multiplier`.
112+
113+
### Transition types
114+
115+
- **Add**: DOM is updated first (new element inserted, `has-detail` set), then the detail slides in from off-screen
116+
- **Remove**: the detail slides out to off-screen first, then the DOM is updated (element removed, `has-detail` cleared) on `animation.finished`
117+
- **Replace**: 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
118+
119+
The `noAnimation` property (reflected as `no-animation` attribute) skips all animations. Animations are also disabled when `--_transition-duration` resolves to `0s`.
120+
121+
### Transition flow
122+
123+
1. **Capture interrupted position** — read the detail panel's current `translate` via `getComputedStyle()` _before_ cancelling any in-progress animation (see "Smooth interruption" below)
124+
2. **Cancel previous** — cancel in-progress animations, clean up state, resolve the pending promise
125+
3. **Snapshot outgoing** — reassign old content to the outgoing slot (replace only)
126+
4. **DOM update** — run the update callback, apply layout state (add/replace only; remove defers this to step 6)
127+
5. **Animate** — create Web Animations on `translate` using parameters from step 1
128+
6. **Finish** — on `animation.finished`, clean up the `transition` attribute and resolve the promise. For remove, the deferred DOM update runs here
129+
130+
A version counter guards step 6: if a newer transition has started since step 5, the stale finish callback is ignored.
131+
132+
### Smooth interruption
133+
134+
`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` value is read via `getComputedStyle()` _before_ cancelling. This captured mid-flight position becomes the starting keyframe of the new animation, so the panel changes direction smoothly from where it actually is.
135+
136+
For `replace` interruptions, the captured position is applied to the outgoing element (since the interrupted content moves from the detail slot to the outgoing slot).
104137

105-
Uses the CSS View Transitions API (`document.startViewTransition`):
138+
### Outgoing container
106139

107-
- `_setDetail(element, skipTransition)` — adds/replaces/removes detail with animation
108-
- `_startTransition(transitionType, updateCallback)` — starts a named transition
109-
- `_finishTransition()` — calls `__computeLayoutState()` + `__applyLayoutState()` via `queueMicrotask` (see read/write separation above)
110-
- `noAnimation` property disables transitions
111-
- Styles injected via `SlotStylesMixin`
140+
The `#detail-outgoing` shadow DOM element with `<slot name="detail-outgoing">` enables simultaneous replace animations. Old content is moved to this slot (light DOM reassignment preserves user styles), animated out, then removed on completion.
112141

113142
## Test Patterns
114143

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

Lines changed: 67 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,10 @@ export const masterDetailLayoutStyles = css`
1212
--_detail-size: 15em;
1313
--_master-column: var(--_master-size) 0;
1414
--_detail-column: var(--_detail-size) 0;
15+
--_transition-duration: 0s;
16+
--_transition-easing: cubic-bezier(0.78, 0, 0.22, 1);
17+
--_rtl-multiplier: 1;
18+
--_detail-offscreen: calc((100% + 30px) * var(--_rtl-multiplier));
1519
1620
display: grid;
1721
box-sizing: border-box;
@@ -27,21 +31,29 @@ export const masterDetailLayoutStyles = css`
2731
display: none !important;
2832
}
2933
34+
:host([dir='rtl']) {
35+
--_rtl-multiplier: -1;
36+
}
37+
3038
:host([orientation='vertical']) {
39+
--_detail-offscreen: 0 calc(100% + 30px);
40+
3141
grid-template-columns: 100%;
3242
grid-template-rows: [master-start] var(--_master-column) [detail-start] var(--_detail-column) [detail-end];
3343
}
3444
3545
#master,
36-
#detail {
46+
#detail,
47+
#outgoing {
3748
box-sizing: border-box;
3849
}
3950
4051
#master {
4152
grid-column: master-start / detail-start;
4253
}
4354
44-
#detail {
55+
#detail,
56+
#outgoing {
4557
grid-column: detail-start / detail-end;
4658
}
4759
@@ -50,7 +62,8 @@ export const masterDetailLayoutStyles = css`
5062
grid-row: master-start / detail-start;
5163
}
5264
53-
:host([orientation='vertical']) #detail {
65+
:host([orientation='vertical']) #detail,
66+
:host([orientation='vertical']) #outgoing {
5467
grid-column: auto;
5568
grid-row: detail-start / detail-end;
5669
}
@@ -89,7 +102,41 @@ export const masterDetailLayoutStyles = css`
89102
var(--vaadin-master-detail-layout-border-color, var(--vaadin-border-color-secondary));
90103
}
91104
92-
:host([overflow]) #detail {
105+
/* Detail transition: off-screen by default, on-screen when has-detail */
106+
#detail {
107+
translate: var(--_detail-offscreen);
108+
}
109+
110+
:host([has-detail]) #detail {
111+
translate: none;
112+
}
113+
114+
/* During replace, both detail elements must overlap in the same grid
115+
cell. Without explicit placement on the non-positioned axis, the
116+
second element is auto-placed into an implicit track. In split mode,
117+
the outgoing cross-fades out and needs an opaque background so
118+
transparent areas don't reveal the incoming prematurely. In overlay
119+
mode, the [overflow] rule already provides the background. */
120+
:host(:not([overflow])[transition='replace']) #outgoing {
121+
background: var(--vaadin-master-detail-layout-detail-background, var(--vaadin-background-color));
122+
}
123+
124+
:host(:not([orientation='vertical'])[transition='replace']) #detail,
125+
:host(:not([orientation='vertical'])[transition='replace']) #outgoing {
126+
grid-row: 1 / -1;
127+
}
128+
129+
:host([orientation='vertical'][transition='replace']) #detail,
130+
:host([orientation='vertical'][transition='replace']) #outgoing {
131+
grid-column: 1 / -1;
132+
}
133+
134+
#outgoing:not([hidden]) {
135+
z-index: 1;
136+
}
137+
138+
:host([overflow]) #detail,
139+
:host([overflow]) #outgoing {
93140
position: absolute;
94141
z-index: 2;
95142
background: var(--vaadin-master-detail-layout-detail-background, var(--vaadin-background-color));
@@ -101,13 +148,15 @@ export const masterDetailLayoutStyles = css`
101148
display: block;
102149
}
103150
104-
:host([overflow]:not([orientation='vertical'])) #detail {
151+
:host([overflow]:not([orientation='vertical'])) #detail,
152+
:host([overflow]:not([orientation='vertical'])) #outgoing {
105153
inset-block: 0;
106154
width: var(--_overlay-size, var(--_detail-size, min-content));
107155
inset-inline-end: 0;
108156
}
109157
110-
:host([overflow][orientation='vertical']) #detail {
158+
:host([overflow][orientation='vertical']) #detail,
159+
:host([overflow][orientation='vertical']) #outgoing {
111160
grid-column: auto;
112161
grid-row: none;
113162
inset-inline: 0;
@@ -116,17 +165,27 @@ export const masterDetailLayoutStyles = css`
116165
}
117166
118167
:host([overflow][overlay-containment='viewport']) #detail,
168+
:host([overflow][overlay-containment='viewport']) #outgoing,
119169
:host([overflow][overlay-containment='viewport']) [part~='backdrop'] {
120170
position: fixed;
121171
}
122172
123173
@media (forced-colors: active) {
124-
:host([overflow]) #detail {
174+
:host([overflow]) #detail,
175+
:host([overflow]) #outgoing {
125176
outline: 3px solid !important;
126177
}
127178
128-
#detail {
179+
#detail,
180+
#outgoing {
129181
background: Canvas !important;
130182
}
131183
}
184+
185+
/* Enable transitions when motion is allowed */
186+
@media (prefers-reduced-motion: no-preference) {
187+
:host(:not([no-animation])) {
188+
--_transition-duration: 400ms;
189+
}
190+
}
132191
`;

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

Lines changed: 0 additions & 107 deletions
This file was deleted.

packages/master-detail-layout/src/vaadin-master-detail-layout.d.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@
44
* This program is available under Apache License Version 2.0, available at https://vaadin.com/license/
55
*/
66
import { ElementMixin } from '@vaadin/component-base/src/element-mixin.js';
7-
import { SlotStylesMixin } from '@vaadin/component-base/src/slot-styles-mixin.js';
87
import { ThemableMixin } from '@vaadin/vaadin-themable-mixin/vaadin-themable-mixin.js';
98

109
export interface MasterDetailLayoutCustomEventMap {
@@ -55,7 +54,7 @@ export interface MasterDetailLayoutEventMap extends HTMLElementEventMap, MasterD
5554
* @fires {CustomEvent} backdrop-click - Fired when the user clicks the backdrop in the overlay mode.
5655
* @fires {CustomEvent} detail-escape-press - Fired when the user presses Escape in the detail area.
5756
*/
58-
declare class MasterDetailLayout extends SlotStylesMixin(ThemableMixin(ElementMixin(HTMLElement))) {
57+
declare class MasterDetailLayout extends ThemableMixin(ElementMixin(HTMLElement)) {
5958
/**
6059
* Size (in CSS length units) to be set on the detail area in
6160
* the CSS grid layout. If there is not enough space to show

0 commit comments

Comments
 (0)