@vaadin/master-detail-layout 25.2.0-alpha5 → 25.2.0-alpha6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -17,6 +17,226 @@
17
17
  }
18
18
  ]
19
19
  },
20
+ {
21
+ "kind": "javascript-module",
22
+ "path": "src/vaadin-master-detail-layout-helpers.js",
23
+ "declarations": [
24
+ {
25
+ "kind": "function",
26
+ "name": "getCurrentAnimation",
27
+ "parameters": [
28
+ {
29
+ "name": "element",
30
+ "type": {
31
+ "text": "HTMLElement"
32
+ }
33
+ }
34
+ ],
35
+ "description": "Returns the currently running master-detail-layout animation on the\nelement, if any. Matches by the shared animation ID and `'running'`\nplay state.",
36
+ "return": {
37
+ "type": {
38
+ "text": "Animation | undefined"
39
+ }
40
+ }
41
+ },
42
+ {
43
+ "kind": "function",
44
+ "name": "getCurrentAnimationProgress",
45
+ "parameters": [
46
+ {
47
+ "name": "element",
48
+ "type": {
49
+ "text": "HTMLElement"
50
+ }
51
+ }
52
+ ],
53
+ "description": "Returns the overall progress (0–1) of the current animation on the\nelement, computed as `currentTime / duration`. Returns 0 when no\nanimation is running.",
54
+ "return": {
55
+ "type": {
56
+ "text": "number"
57
+ }
58
+ }
59
+ },
60
+ {
61
+ "kind": "function",
62
+ "name": "animateIn",
63
+ "parameters": [
64
+ {
65
+ "name": "element",
66
+ "type": {
67
+ "text": "HTMLElement"
68
+ }
69
+ },
70
+ {
71
+ "name": "effects",
72
+ "type": {
73
+ "text": "Array<'fade' | 'slide'>"
74
+ }
75
+ },
76
+ {
77
+ "name": "progress",
78
+ "description": "starting progress (0–1) for interrupted resumption",
79
+ "type": {
80
+ "text": "number"
81
+ }
82
+ }
83
+ ],
84
+ "description": "Runs an enter animation on the element.",
85
+ "return": {
86
+ "type": {
87
+ "text": "Promise<void>"
88
+ }
89
+ }
90
+ },
91
+ {
92
+ "kind": "function",
93
+ "name": "animateOut",
94
+ "parameters": [
95
+ {
96
+ "name": "element",
97
+ "type": {
98
+ "text": "HTMLElement"
99
+ }
100
+ },
101
+ {
102
+ "name": "effects",
103
+ "type": {
104
+ "text": "Array<'fade' | 'slide'>"
105
+ }
106
+ },
107
+ {
108
+ "name": "progress",
109
+ "description": "starting progress (0–1) for interrupted resumption",
110
+ "type": {
111
+ "text": "number"
112
+ }
113
+ }
114
+ ],
115
+ "description": "Runs an exit animation on the element.",
116
+ "return": {
117
+ "type": {
118
+ "text": "Promise<void>"
119
+ }
120
+ }
121
+ },
122
+ {
123
+ "kind": "function",
124
+ "name": "cancelAnimations",
125
+ "parameters": [
126
+ {
127
+ "name": "element",
128
+ "type": {
129
+ "text": "HTMLElement"
130
+ }
131
+ }
132
+ ],
133
+ "description": "Cancels all running animations on the element that match the shared animation ID."
134
+ },
135
+ {
136
+ "kind": "function",
137
+ "name": "parseTrackSizes",
138
+ "parameters": [
139
+ {
140
+ "name": "gridTemplate",
141
+ "description": "computed grid template string (e.g. `\"200px [gap] 10px 400px\"`)",
142
+ "type": {
143
+ "text": "string"
144
+ }
145
+ }
146
+ ],
147
+ "description": "Parses a computed `gridTemplateColumns` / `gridTemplateRows` value\ninto an array of track sizes in pixels. Line names (e.g. `[name]`)\nare stripped before parsing.",
148
+ "return": {
149
+ "type": {
150
+ "text": "number[]"
151
+ }
152
+ }
153
+ },
154
+ {
155
+ "kind": "function",
156
+ "name": "detectOverflow",
157
+ "parameters": [
158
+ {
159
+ "name": "hostSize",
160
+ "description": "the host element's width or height in pixels",
161
+ "type": {
162
+ "text": "number"
163
+ }
164
+ },
165
+ {
166
+ "name": "trackSizes",
167
+ "description": "[masterSize, masterExtra, detailSize] in pixels",
168
+ "type": {
169
+ "text": "number[]"
170
+ }
171
+ }
172
+ ],
173
+ "description": "Determines whether the detail area overflows the host element,\nmeaning it should be shown as an overlay instead of side-by-side.\n\nReturns `false` when all tracks fit within the host, or when the\nmaster's extra space (flexible portion) is large enough to absorb\nthe detail column.",
174
+ "return": {
175
+ "type": {
176
+ "text": "boolean"
177
+ }
178
+ }
179
+ }
180
+ ],
181
+ "exports": [
182
+ {
183
+ "kind": "js",
184
+ "name": "getCurrentAnimation",
185
+ "declaration": {
186
+ "name": "getCurrentAnimation",
187
+ "module": "src/vaadin-master-detail-layout-helpers.js"
188
+ }
189
+ },
190
+ {
191
+ "kind": "js",
192
+ "name": "getCurrentAnimationProgress",
193
+ "declaration": {
194
+ "name": "getCurrentAnimationProgress",
195
+ "module": "src/vaadin-master-detail-layout-helpers.js"
196
+ }
197
+ },
198
+ {
199
+ "kind": "js",
200
+ "name": "animateIn",
201
+ "declaration": {
202
+ "name": "animateIn",
203
+ "module": "src/vaadin-master-detail-layout-helpers.js"
204
+ }
205
+ },
206
+ {
207
+ "kind": "js",
208
+ "name": "animateOut",
209
+ "declaration": {
210
+ "name": "animateOut",
211
+ "module": "src/vaadin-master-detail-layout-helpers.js"
212
+ }
213
+ },
214
+ {
215
+ "kind": "js",
216
+ "name": "cancelAnimations",
217
+ "declaration": {
218
+ "name": "cancelAnimations",
219
+ "module": "src/vaadin-master-detail-layout-helpers.js"
220
+ }
221
+ },
222
+ {
223
+ "kind": "js",
224
+ "name": "parseTrackSizes",
225
+ "declaration": {
226
+ "name": "parseTrackSizes",
227
+ "module": "src/vaadin-master-detail-layout-helpers.js"
228
+ }
229
+ },
230
+ {
231
+ "kind": "js",
232
+ "name": "detectOverflow",
233
+ "declaration": {
234
+ "name": "detectOverflow",
235
+ "module": "src/vaadin-master-detail-layout-helpers.js"
236
+ }
237
+ }
238
+ ]
239
+ },
20
240
  {
21
241
  "kind": "javascript-module",
22
242
  "path": "src/vaadin-master-detail-layout.js",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vaadin/master-detail-layout",
3
- "version": "25.2.0-alpha5",
3
+ "version": "25.2.0-alpha6",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -34,16 +34,16 @@
34
34
  "web-component"
35
35
  ],
36
36
  "dependencies": {
37
- "@vaadin/a11y-base": "25.2.0-alpha5",
38
- "@vaadin/component-base": "25.2.0-alpha5",
39
- "@vaadin/vaadin-themable-mixin": "25.2.0-alpha5",
37
+ "@vaadin/a11y-base": "25.2.0-alpha6",
38
+ "@vaadin/component-base": "25.2.0-alpha6",
39
+ "@vaadin/vaadin-themable-mixin": "25.2.0-alpha6",
40
40
  "lit": "^3.0.0"
41
41
  },
42
42
  "devDependencies": {
43
- "@vaadin/aura": "25.2.0-alpha5",
44
- "@vaadin/chai-plugins": "25.2.0-alpha5",
43
+ "@vaadin/aura": "25.2.0-alpha6",
44
+ "@vaadin/chai-plugins": "25.2.0-alpha6",
45
45
  "@vaadin/testing-helpers": "^2.0.0",
46
- "@vaadin/vaadin-lumo-styles": "25.2.0-alpha5",
46
+ "@vaadin/vaadin-lumo-styles": "25.2.0-alpha6",
47
47
  "sinon": "^21.0.2"
48
48
  },
49
49
  "customElements": "custom-elements.json",
@@ -51,5 +51,5 @@
51
51
  "web-types.json",
52
52
  "web-types.lit.json"
53
53
  ],
54
- "gitHead": "2f0c822a389571591b1b9d2c27d45e008ccbae6b"
54
+ "gitHead": "30f23c65765f27616f2db292406d5759a7e987c3"
55
55
  }
@@ -14,10 +14,10 @@ export const masterDetailLayoutStyles = css`
14
14
  --_detail-extra: 0px;
15
15
  --_detail-cached-size: min-content;
16
16
 
17
+ --_rtl-multiplier: 1;
17
18
  --_transition-duration: 0s;
18
19
  --_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));
21
21
 
22
22
  display: grid;
23
23
  box-sizing: border-box;
@@ -42,7 +42,7 @@ export const masterDetailLayoutStyles = css`
42
42
  }
43
43
 
44
44
  :host([orientation='vertical']) {
45
- --_detail-offscreen: 0 30px;
45
+ --_transition-offset: 0 30px;
46
46
 
47
47
  grid-template-columns: 100%;
48
48
  grid-template-rows:
@@ -87,6 +87,8 @@ export const masterDetailLayoutStyles = css`
87
87
  }
88
88
 
89
89
  #backdrop {
90
+ --_transition-easing: linear;
91
+
90
92
  position: absolute;
91
93
  inset: 0;
92
94
  z-index: 2;
@@ -135,7 +137,7 @@ export const masterDetailLayoutStyles = css`
135
137
 
136
138
  /* Detail transition: off-screen by default, on-screen when has-detail */
137
139
  #detail {
138
- translate: var(--_detail-offscreen);
140
+ translate: var(--_transition-offset);
139
141
  opacity: 0;
140
142
  z-index: 4;
141
143
  }
@@ -146,11 +148,11 @@ export const masterDetailLayoutStyles = css`
146
148
  }
147
149
 
148
150
  :host([overlay]) {
149
- --_detail-offscreen: calc((100% + 30px) * var(--_rtl-multiplier));
151
+ --_transition-offset: calc((100% + 30px) * var(--_rtl-multiplier));
150
152
  }
151
153
 
152
154
  :host([overlay][orientation='vertical']) {
153
- --_detail-offscreen: 0 calc(100% + 30px);
155
+ --_transition-offset: 0 calc(100% + 30px);
154
156
  }
155
157
 
156
158
  :host([has-detail][overlay]) :is(#detail, #outgoing) {
@@ -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
+ }
@@ -11,27 +11,14 @@ import { ElementMixin } from '@vaadin/component-base/src/element-mixin.js';
11
11
  import { PolylitMixin } from '@vaadin/component-base/src/polylit-mixin.js';
12
12
  import { ThemableMixin } from '@vaadin/vaadin-themable-mixin/vaadin-themable-mixin.js';
13
13
  import { masterDetailLayoutStyles } from './styles/vaadin-master-detail-layout-base-styles.js';
14
-
15
- function parseTrackSizes(gridTemplate) {
16
- return gridTemplate
17
- .replace(/\[[^\]]+\]/gu, '')
18
- .replace(/\s+/gu, ' ')
19
- .trim()
20
- .split(' ')
21
- .map(parseFloat);
22
- }
23
-
24
- function detectOverflow(hostSize, trackSizes) {
25
- const [masterSize, masterExtra, detailSize] = trackSizes;
26
-
27
- if (Math.floor(masterSize + masterExtra + detailSize) <= Math.floor(hostSize)) {
28
- return false;
29
- }
30
- if (Math.floor(masterExtra) >= Math.floor(detailSize)) {
31
- return false;
32
- }
33
- return true;
34
- }
14
+ import {
15
+ animateIn,
16
+ animateOut,
17
+ cancelAnimations,
18
+ detectOverflow,
19
+ getCurrentAnimationProgress,
20
+ parseTrackSizes,
21
+ } from './vaadin-master-detail-layout-helpers.js';
35
22
 
36
23
  /**
37
24
  * `<vaadin-master-detail-layout>` is a web component for building UIs with a master
@@ -274,7 +261,7 @@ class MasterDetailLayout extends ElementMixin(ThemableMixin(PolylitMixin(LitElem
274
261
  super.disconnectedCallback();
275
262
  this.__resizeObserver.disconnect();
276
263
  cancelAnimationFrame(this.__resizeRaf);
277
- this.__endTransition();
264
+ cancelAnimations(this);
278
265
  }
279
266
 
280
267
  /** @private */
@@ -507,65 +494,42 @@ class MasterDetailLayout extends ElementMixin(ThemableMixin(PolylitMixin(LitElem
507
494
  * @return {Promise<void>}
508
495
  * @protected
509
496
  */
510
- _setDetail(element, skipTransition) {
497
+ async _setDetail(newDetail, skipTransition) {
511
498
  // Don't start a transition if detail didn't change
512
- const currentDetail = this.querySelector('[slot="detail"]');
513
- if ((element || null) === currentDetail) {
514
- return Promise.resolve();
499
+ const oldDetail = this.querySelector('[slot="detail"]');
500
+ if (oldDetail === (newDetail || null)) {
501
+ return;
515
502
  }
516
503
 
517
- const updateSlot = () => {
518
- // Remove old content
519
- this.querySelectorAll('[slot="detail"]').forEach((oldElement) => oldElement.remove());
520
- // Add new content
521
- if (element) {
522
- element.setAttribute('slot', 'detail');
523
- this.appendChild(element);
504
+ const updateSlot = async () => {
505
+ if (oldDetail && oldDetail.slot === 'detail') {
506
+ oldDetail.remove();
524
507
  }
525
- };
526
508
 
527
- if (skipTransition || this.noAnimation) {
528
- updateSlot();
529
- queueMicrotask(() => this.recalculateLayout());
530
- return Promise.resolve();
531
- }
509
+ if (newDetail) {
510
+ newDetail.setAttribute('slot', 'detail');
511
+ this.appendChild(newDetail);
512
+ }
532
513
 
533
- const transitionType = this.__getTransitionType(currentDetail, element);
514
+ // Wait for Lit elements to render
515
+ await Promise.resolve();
534
516
 
535
- return this._startTransition(transitionType, () => {
536
- // Update the DOM
537
- updateSlot();
538
- // Finish the transition
539
- this._finishTransition();
540
- });
541
- }
517
+ this.recalculateLayout();
518
+ };
542
519
 
543
- /**
544
- * Determines the transition type for a detail change.
545
- *
546
- * Returns 'replace' in two cases:
547
- * - Swapping one detail for another (standard replace).
548
- * - Swapping between placeholder and detail in split mode,
549
- * so the swap appears instant (replace has 0ms duration in split).
550
- * In overlay mode, placeholder doesn't participate in transitions,
551
- * so standard 'add'/'remove' are used instead.
552
- *
553
- * @param {Element | null} currentDetail
554
- * @param {Element | null} newDetail
555
- * @return {string}
556
- * @private
557
- */
558
- __getTransitionType(currentDetail, newDetail) {
559
- if (currentDetail && newDetail) {
560
- return 'replace';
520
+ if (skipTransition || this.noAnimation) {
521
+ await updateSlot();
522
+ return;
561
523
  }
562
524
 
563
525
  const hasPlaceholder = !!this.querySelector('[slot="detail-placeholder"]');
564
- if (hasPlaceholder && !this.hasAttribute('overlay')) {
565
- return 'replace';
526
+ if ((oldDetail && newDetail) || (hasPlaceholder && !this.hasAttribute('overlay'))) {
527
+ await this._startTransition('replace', updateSlot);
528
+ } else if (!oldDetail && newDetail) {
529
+ await this._startTransition('add', updateSlot);
530
+ } else if (oldDetail && !newDetail) {
531
+ await this._startTransition('remove', updateSlot);
566
532
  }
567
-
568
- return currentDetail ? 'remove' : 'add';
569
533
  }
570
534
 
571
535
  /**
@@ -583,204 +547,77 @@ class MasterDetailLayout extends ElementMixin(ThemableMixin(PolylitMixin(LitElem
583
547
  * and picks up from the interrupted position.
584
548
  *
585
549
  * @param transitionType
586
- * @param updateCallback
550
+ * @param updateSlot
587
551
  * @return {Promise<void>}
588
552
  * @protected
589
553
  */
590
- async _startTransition(transitionType, updateCallback) {
554
+ async _startTransition(transitionType, updateSlot) {
591
555
  if (this.noAnimation) {
592
- updateCallback();
556
+ await updateSlot();
593
557
  return;
594
558
  }
595
559
 
596
- // Capture mid-flight state before cancelling active animations
597
- const interrupted = this.__captureDetailState();
598
-
599
- this.__endTransition();
600
-
601
- if (transitionType === 'replace') {
602
- this.__snapshotOutgoing();
603
- }
604
-
605
- this.setAttribute('transition', transitionType);
606
-
607
- const version = (this.__transitionVersion = (this.__transitionVersion || 0) + 1);
608
-
609
- if (transitionType !== 'remove') {
610
- // Add/Replace: update DOM, wait for Lit rendering + recalculateLayout
611
- updateCallback();
612
- await Promise.resolve();
613
- if (this.__transitionVersion !== version) return;
614
- }
615
-
616
- const opts = this.__getAnimationParams();
617
- opts.interrupted = interrupted;
618
- opts.overlay = this.hasAttribute('overlay');
619
-
620
- // Run animations and wait for the detail slide to finish
621
- await this.__runAnimations(transitionType, opts);
622
- if (this.__transitionVersion !== version) return;
623
-
624
- if (transitionType === 'remove') {
625
- // Remove: deferred DOM update after slide-out completes
626
- updateCallback();
627
- await Promise.resolve();
628
- }
629
-
630
- this.__endTransition();
631
- }
632
-
633
- /**
634
- * Starts slide animation(s) for the given transition type and returns
635
- * a promise that resolves when the detail slide completes.
636
- *
637
- * @param {string} transitionType
638
- * @param {{ offscreen: string, duration: number, easing: string, interrupted?: { translate: string, opacity: string }, overlay?: boolean }} opts
639
- * @return {Promise<void>}
640
- * @private
641
- */
642
- __runAnimations(transitionType, opts) {
643
- let slide;
644
-
645
- if (transitionType === 'remove') {
646
- slide = this.__slide(this.$.detail, false, opts);
647
- } else if (transitionType === 'replace') {
648
- // Outgoing slides out while incoming is revealed underneath.
649
- // In overlay mode, the incoming also slides in simultaneously.
650
- slide = this.__slide(this.$.outgoing, false, opts);
651
- if (opts.overlay) {
652
- this.__slide(this.$.detail, true, { ...opts, interrupted: null });
560
+ try {
561
+ this.setAttribute('transition', transitionType);
562
+
563
+ switch (transitionType) {
564
+ case 'add':
565
+ await this.__addTransition(updateSlot);
566
+ break;
567
+ case 'remove':
568
+ await this.__removeTransition(updateSlot);
569
+ break;
570
+ default:
571
+ await this.__replaceTransition(updateSlot);
572
+ break;
653
573
  }
654
- } else {
655
- slide = this.__slide(this.$.detail, true, opts);
656
- }
657
-
658
- // Fade backdrop in/out for overlay add/remove (not replace — backdrop stays visible)
659
- if (opts.overlay && transitionType !== 'replace') {
660
- const fadeIn = transitionType !== 'remove';
661
- this.__animate(this.$.backdrop, [{ opacity: fadeIn ? 0 : 1 }, { opacity: fadeIn ? 1 : 0 }], {
662
- duration: opts.duration,
663
- easing: 'linear',
664
- });
665
- }
666
-
667
- return slide;
668
- }
669
-
670
- /**
671
- * Finishes the current transition by detecting and applying the layout
672
- * state. This method should be called after the DOM has been updated.
673
- *
674
- * @protected
675
- */
676
- _finishTransition() {
677
- queueMicrotask(() => this.recalculateLayout());
678
- }
679
574
 
680
- /**
681
- * Captures the detail panel's current animated state (translate and
682
- * opacity). Must be called BEFORE `animation.cancel()`, because
683
- * cancel removes the animation effect and the element reverts to
684
- * its CSS resting state.
685
- *
686
- * Returns null when there is no active animation.
687
- *
688
- * @return {{ translate: string, opacity: string } | null}
689
- * @private
690
- */
691
- __captureDetailState() {
692
- if (!this.__activeAnimations || this.__activeAnimations.length === 0) {
693
- return null;
575
+ this.removeAttribute('transition');
576
+ } catch (e) {
577
+ if (e instanceof DOMException && e.name === 'AbortError') {
578
+ return; // Animation was cancelled
579
+ }
580
+ throw e;
694
581
  }
695
- const { translate, opacity } = getComputedStyle(this.$.detail);
696
- return { translate, opacity };
697
- }
698
-
699
- /**
700
- * Reads animation parameters from CSS custom properties. Called once
701
- * per transition so that animating stays free of layout reads.
702
- *
703
- * @return {{ offscreen: string, duration: number, easing: string }}
704
- * @private
705
- */
706
- __getAnimationParams() {
707
- const cs = getComputedStyle(this);
708
- const offscreen = cs.getPropertyValue('--_detail-offscreen').trim();
709
- const durationStr = cs.getPropertyValue('--_transition-duration').trim();
710
- const duration = durationStr.endsWith('ms') ? parseFloat(durationStr) : parseFloat(durationStr) * 1000;
711
- const easing = cs.getPropertyValue('--_transition-easing').trim();
712
- return { offscreen, duration, easing };
713
582
  }
714
583
 
715
- /**
716
- * Creates a slide animation on the element's `translate` property
717
- * using the Web Animations API. Returns a promise that resolves when
718
- * the animation finishes, or immediately if the duration is 0.
719
- *
720
- * @param {HTMLElement} element - The element to animate
721
- * @param {boolean} slideIn - If true, slide in (off-screen → on-screen);
722
- * otherwise slide out (on-screen → off-screen)
723
- * @param {{ offscreen: string, duration: number, easing: string, interrupted?: { translate: string, opacity: string }, overlay?: boolean }} opts
724
- * Animation parameters. `interrupted` overrides the default starting
725
- * keyframe for interrupted animations (captured mid-flight before cancel).
726
- * @return {Promise<void>}
727
- * @private
728
- */
729
- __slide(element, slideIn, { offscreen, duration, easing, interrupted, overlay }) {
730
- if (!offscreen || duration <= 0) {
731
- return Promise.resolve();
732
- }
733
-
734
- const defaultTranslate = slideIn ? offscreen : 'none';
735
- const defaultOpacity = !overlay && slideIn ? 0 : 1;
736
-
737
- const start = interrupted ? interrupted.translate : defaultTranslate;
738
- const end = slideIn ? 'none' : offscreen;
739
-
740
- const opacityStart = interrupted ? Number(interrupted.opacity) : defaultOpacity;
741
- const opacityEnd = !overlay && !slideIn ? 0 : 1;
584
+ /** @private */
585
+ async __addTransition(updateSlot) {
586
+ await updateSlot();
742
587
 
743
- return this.__animate(
744
- element,
745
- [
746
- { translate: start, opacity: opacityStart },
747
- { translate: end, opacity: opacityEnd },
748
- ],
749
- { duration, easing },
750
- );
588
+ const progress = getCurrentAnimationProgress(this.$.detail);
589
+ await Promise.all([
590
+ animateIn(this.$.detail, ['fade', 'slide'], progress),
591
+ animateIn(this.$.backdrop, ['fade'], this.hasAttribute('overlay') ? progress : 1),
592
+ ]);
751
593
  }
752
594
 
753
- /**
754
- * Runs a Web Animation on the given element, tracks it for cancellation,
755
- * and returns a promise that resolves when finished (or swallows the
756
- * rejection if cancelled).
757
- *
758
- * @param {HTMLElement} element
759
- * @param {Keyframe[]} keyframes
760
- * @param {KeyframeAnimationOptions} options
761
- * @return {Promise<void>}
762
- * @private
763
- */
764
- __animate(element, keyframes, options) {
765
- const animation = element.animate(keyframes, { ...options, fill: 'forwards' });
595
+ /** @private */
596
+ async __replaceTransition(updateSlot) {
597
+ try {
598
+ this.__snapshotOutgoing();
766
599
 
767
- this.__activeAnimations = this.__activeAnimations || [];
768
- this.__activeAnimations.push(animation);
600
+ await updateSlot();
769
601
 
770
- return animation.finished.catch(() => {});
602
+ const progress = getCurrentAnimationProgress(this.$.detail);
603
+ await Promise.all([
604
+ animateIn(this.$.detail, ['fade', 'slide'], progress),
605
+ animateOut(this.$.outgoing, ['fade', 'slide'], progress),
606
+ ]);
607
+ } finally {
608
+ this.__clearOutgoing();
609
+ }
771
610
  }
772
611
 
773
- /**
774
- * Cancels in-progress animations and cleans up transition state.
775
- * @private
776
- */
777
- __endTransition() {
778
- if (this.__activeAnimations) {
779
- this.__activeAnimations.forEach((a) => a.cancel());
780
- this.__activeAnimations = null;
781
- }
782
- this.removeAttribute('transition');
783
- this.__clearOutgoing();
612
+ /** @private */
613
+ async __removeTransition(updateSlot) {
614
+ const progress = getCurrentAnimationProgress(this.$.detail);
615
+ await Promise.all([
616
+ animateOut(this.$.detail, ['fade', 'slide'], progress),
617
+ animateOut(this.$.backdrop, ['fade'], this.hasAttribute('overlay') ? progress : 1),
618
+ ]);
619
+
620
+ await updateSlot();
784
621
  }
785
622
 
786
623
  /**
package/web-types.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://json.schemastore.org/web-types",
3
3
  "name": "@vaadin/master-detail-layout",
4
- "version": "25.2.0-alpha5",
4
+ "version": "25.2.0-alpha6",
5
5
  "description-markup": "markdown",
6
6
  "contributions": {
7
7
  "html": {
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://json.schemastore.org/web-types",
3
3
  "name": "@vaadin/master-detail-layout",
4
- "version": "25.2.0-alpha5",
4
+ "version": "25.2.0-alpha6",
5
5
  "description-markup": "markdown",
6
6
  "framework": "lit",
7
7
  "framework-config": {