@vaadin/master-detail-layout 25.2.0-alpha4 → 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-alpha4",
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-alpha4",
38
- "@vaadin/component-base": "25.2.0-alpha4",
39
- "@vaadin/vaadin-themable-mixin": "25.2.0-alpha4",
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-alpha4",
44
- "@vaadin/chai-plugins": "25.2.0-alpha4",
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-alpha4",
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": "fc054a1bed540874ef3b5000828c191cc12044ec"
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:
@@ -56,11 +56,14 @@ export const masterDetailLayoutStyles = css`
56
56
  }
57
57
 
58
58
  #detail-placeholder {
59
- visibility: hidden;
59
+ z-index: 1;
60
+ opacity: 0;
61
+ pointer-events: none;
60
62
  }
61
63
 
62
64
  :host([has-detail-placeholder]:not([has-detail], [overlay])) #detail-placeholder {
63
- visibility: visible;
65
+ opacity: 1;
66
+ pointer-events: auto;
64
67
  }
65
68
 
66
69
  #master {
@@ -84,9 +87,11 @@ export const masterDetailLayoutStyles = css`
84
87
  }
85
88
 
86
89
  #backdrop {
90
+ --_transition-easing: linear;
91
+
87
92
  position: absolute;
88
93
  inset: 0;
89
- z-index: 1;
94
+ z-index: 2;
90
95
  opacity: 0;
91
96
  pointer-events: none;
92
97
  background: var(--vaadin-overlay-backdrop-background, rgba(0, 0, 0, 0.2));
@@ -125,32 +130,33 @@ export const masterDetailLayoutStyles = css`
125
130
  var(--vaadin-master-detail-layout-border-color, var(--vaadin-border-color-secondary));
126
131
  }
127
132
 
133
+ #outgoing {
134
+ position: absolute;
135
+ z-index: 3;
136
+ }
137
+
128
138
  /* Detail transition: off-screen by default, on-screen when has-detail */
129
139
  #detail {
130
- translate: var(--_detail-offscreen);
131
- visibility: hidden;
140
+ translate: var(--_transition-offset);
141
+ opacity: 0;
142
+ z-index: 4;
132
143
  }
133
144
 
134
145
  :host([has-detail]) #detail {
135
146
  translate: none;
136
- visibility: visible;
137
- }
138
-
139
- #outgoing:not([hidden]) {
140
- z-index: 1;
147
+ opacity: 1;
141
148
  }
142
149
 
143
150
  :host([overlay]) {
144
- --_detail-offscreen: calc((100% + 30px) * var(--_rtl-multiplier));
151
+ --_transition-offset: calc((100% + 30px) * var(--_rtl-multiplier));
145
152
  }
146
153
 
147
154
  :host([overlay][orientation='vertical']) {
148
- --_detail-offscreen: 0 calc(100% + 30px);
155
+ --_transition-offset: 0 calc(100% + 30px);
149
156
  }
150
157
 
151
158
  :host([has-detail][overlay]) :is(#detail, #outgoing) {
152
159
  position: absolute;
153
- z-index: 2;
154
160
  background: var(--vaadin-master-detail-layout-detail-background, var(--vaadin-background-color));
155
161
  box-shadow: var(--vaadin-master-detail-layout-detail-shadow, 0 0 20px 0 rgba(0, 0, 0, 0.3));
156
162
  grid-column: none;
@@ -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,282 +494,130 @@ 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
  /**
572
536
  * Starts an animated transition for adding, replacing or removing the
573
537
  * detail area using the Web Animations API.
574
538
  *
575
- * For 'remove', the DOM update is deferred until the slide-out completes.
576
- * For 'add'/'replace', the DOM is updated immediately and the slide-in
577
- * plays on the new content.
539
+ * For 'add'/'replace': DOM is updated immediately, then animation
540
+ * starts after a microtask (so Lit elements render and layout is
541
+ * recalculated before animation params are read).
578
542
  *
579
- * Animations are interruptible: starting a new transition cancels any
580
- * in-progress animation and the new animation picks up from the
581
- * interrupted position (see `__captureDetailState`).
543
+ * For 'remove': animation plays first, then DOM is updated after
544
+ * the slide-out completes.
545
+ *
546
+ * Interruptible: a new transition cancels any in-progress animation
547
+ * and picks up from the interrupted position.
582
548
  *
583
549
  * @param transitionType
584
- * @param updateCallback
550
+ * @param updateSlot
585
551
  * @return {Promise<void>}
586
552
  * @protected
587
553
  */
588
- _startTransition(transitionType, updateCallback) {
554
+ async _startTransition(transitionType, updateSlot) {
589
555
  if (this.noAnimation) {
590
- updateCallback();
591
- return Promise.resolve();
592
- }
593
-
594
- // Capture mid-flight state before cancelling active animations
595
- const interrupted = this.__captureDetailState();
596
-
597
- this.__endTransition();
598
-
599
- if (transitionType === 'replace') {
600
- this.__snapshotOutgoing();
601
- }
602
-
603
- this.setAttribute('transition', transitionType);
604
-
605
- if (transitionType !== 'remove') {
606
- updateCallback();
556
+ await updateSlot();
557
+ return;
607
558
  }
608
559
 
609
- const opts = this.__getAnimationParams();
610
- opts.interrupted = interrupted;
611
- opts.overlay = this.hasAttribute('overlay');
612
-
613
- return this.__animateTransition(transitionType, opts, updateCallback);
614
- }
615
-
616
- /**
617
- * Creates slide animation(s) for the given transition type and returns
618
- * a promise that resolves when the primary animation completes.
619
- * A version counter prevents stale callbacks from executing after
620
- * a newer transition has started.
621
- *
622
- * @param {string} transitionType
623
- * @param {{ offscreen: string, duration: number, easing: string, interrupted?: { translate: string, opacity: string }, overlay?: boolean }} opts
624
- * @param {Function} updateCallback
625
- * @return {Promise<void>}
626
- * @private
627
- */
628
- __animateTransition(transitionType, opts, updateCallback) {
629
- const version = (this.__transitionVersion = (this.__transitionVersion || 0) + 1);
630
-
631
- return new Promise((resolve) => {
632
- this.__transitionResolve = resolve;
633
-
634
- const onFinish = (callback) => {
635
- if (this.__transitionVersion === version) {
636
- if (callback) {
637
- callback();
638
- }
639
- this.__endTransition();
640
- }
641
- };
642
-
643
- if (transitionType === 'remove') {
644
- this.__slide(this.$.detail, false, opts).then(() => onFinish(updateCallback));
645
- } else if (transitionType === 'replace') {
646
- // Outgoing slides out on top (z-index), revealing incoming underneath.
647
- // In overlay mode, the incoming also slides in simultaneously.
648
- this.__slide(this.$.outgoing, false, opts).then(() => onFinish());
649
- if (opts.overlay) {
650
- this.__slide(this.$.detail, true, { ...opts, interrupted: null });
651
- }
652
- } else {
653
- this.__slide(this.$.detail, true, opts).then(() => onFinish());
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;
654
573
  }
655
574
 
656
- // Fade backdrop in/out for overlay add/remove (not replace — backdrop stays visible)
657
- if (opts.overlay && transitionType !== 'replace') {
658
- const fadeIn = transitionType !== 'remove';
659
- this.__animate(this.$.backdrop, [{ opacity: fadeIn ? 0 : 1 }, { opacity: fadeIn ? 1 : 0 }], {
660
- duration: opts.duration,
661
- easing: 'linear',
662
- });
575
+ this.removeAttribute('transition');
576
+ } catch (e) {
577
+ if (e instanceof DOMException && e.name === 'AbortError') {
578
+ return; // Animation was cancelled
663
579
  }
664
- });
665
- }
666
-
667
- /**
668
- * Finishes the current transition by detecting and applying the layout
669
- * state. This method should be called after the DOM has been updated.
670
- *
671
- * @protected
672
- */
673
- _finishTransition() {
674
- queueMicrotask(() => this.recalculateLayout());
675
- }
676
-
677
- /**
678
- * Captures the detail panel's current animated state (translate and
679
- * opacity). Must be called BEFORE `animation.cancel()`, because
680
- * cancel removes the animation effect and the element reverts to
681
- * its CSS resting state.
682
- *
683
- * Returns null when there is no active animation.
684
- *
685
- * @return {{ translate: string, opacity: string } | null}
686
- * @private
687
- */
688
- __captureDetailState() {
689
- if (!this.__activeAnimations || this.__activeAnimations.length === 0) {
690
- return null;
580
+ throw e;
691
581
  }
692
- const { translate, opacity } = getComputedStyle(this.$.detail);
693
- return { translate, opacity };
694
582
  }
695
583
 
696
- /**
697
- * Reads animation parameters from CSS custom properties. Called once
698
- * per transition so that animating stays free of layout reads.
699
- *
700
- * @return {{ offscreen: string, duration: number, easing: string }}
701
- * @private
702
- */
703
- __getAnimationParams() {
704
- const cs = getComputedStyle(this);
705
- const offscreen = cs.getPropertyValue('--_detail-offscreen').trim();
706
- const durationStr = cs.getPropertyValue('--_transition-duration').trim();
707
- const duration = durationStr.endsWith('ms') ? parseFloat(durationStr) : parseFloat(durationStr) * 1000;
708
- const easing = cs.getPropertyValue('--_transition-easing').trim();
709
- return { offscreen, duration, easing };
710
- }
711
-
712
- /**
713
- * Creates a slide animation on the element's `translate` property
714
- * using the Web Animations API. Returns a promise that resolves when
715
- * the animation finishes, or immediately if the duration is 0.
716
- *
717
- * @param {HTMLElement} element - The element to animate
718
- * @param {boolean} slideIn - If true, slide in (off-screen → on-screen);
719
- * otherwise slide out (on-screen → off-screen)
720
- * @param {{ offscreen: string, duration: number, easing: string, interrupted?: { translate: string, opacity: string }, overlay?: boolean }} opts
721
- * Animation parameters. `interrupted` overrides the default starting
722
- * keyframe for interrupted animations (captured mid-flight before cancel).
723
- * @return {Promise<void>}
724
- * @private
725
- */
726
- __slide(element, slideIn, { offscreen, duration, easing, interrupted, overlay }) {
727
- if (!offscreen || duration <= 0) {
728
- return Promise.resolve();
729
- }
730
-
731
- const defaultTranslate = slideIn ? offscreen : 'none';
732
- const defaultOpacity = !overlay && slideIn ? 0 : 1;
733
-
734
- const start = interrupted ? interrupted.translate : defaultTranslate;
735
- const end = slideIn ? 'none' : offscreen;
736
-
737
- const opacityStart = interrupted ? Number(interrupted.opacity) : defaultOpacity;
738
- const opacityEnd = !overlay && !slideIn ? 0 : 1;
584
+ /** @private */
585
+ async __addTransition(updateSlot) {
586
+ await updateSlot();
739
587
 
740
- return this.__animate(
741
- element,
742
- [
743
- { translate: start, opacity: opacityStart },
744
- { translate: end, opacity: opacityEnd },
745
- ],
746
- { duration, easing },
747
- );
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
+ ]);
748
593
  }
749
594
 
750
- /**
751
- * Runs a Web Animation on the given element, tracks it for cancellation,
752
- * and returns a promise that resolves when finished (or swallows the
753
- * rejection if cancelled).
754
- *
755
- * @param {HTMLElement} element
756
- * @param {Keyframe[]} keyframes
757
- * @param {KeyframeAnimationOptions} options
758
- * @return {Promise<void>}
759
- * @private
760
- */
761
- __animate(element, keyframes, options) {
762
- const animation = element.animate(keyframes, { ...options, fill: 'forwards' });
595
+ /** @private */
596
+ async __replaceTransition(updateSlot) {
597
+ try {
598
+ this.__snapshotOutgoing();
763
599
 
764
- this.__activeAnimations = this.__activeAnimations || [];
765
- this.__activeAnimations.push(animation);
600
+ await updateSlot();
766
601
 
767
- 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
+ }
768
610
  }
769
611
 
770
- /**
771
- * Cancels in-progress animations, cleans up state, and resolves the
772
- * pending transition promise.
773
- * @private
774
- */
775
- __endTransition() {
776
- if (this.__activeAnimations) {
777
- this.__activeAnimations.forEach((a) => a.cancel());
778
- this.__activeAnimations = null;
779
- }
780
- this.removeAttribute('transition');
781
- this.__clearOutgoing();
782
- if (this.__transitionResolve) {
783
- this.__transitionResolve();
784
- this.__transitionResolve = null;
785
- }
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();
786
621
  }
787
622
 
788
623
  /**
@@ -797,6 +632,7 @@ class MasterDetailLayout extends ElementMixin(ThemableMixin(PolylitMixin(LitElem
797
632
  return;
798
633
  }
799
634
  currentDetail.setAttribute('slot', 'detail-outgoing');
635
+ this.$.outgoing.style.width = this.__detailCachedSize;
800
636
  this.__replacing = true;
801
637
  }
802
638
 
@@ -806,6 +642,7 @@ class MasterDetailLayout extends ElementMixin(ThemableMixin(PolylitMixin(LitElem
806
642
  */
807
643
  __clearOutgoing() {
808
644
  this.querySelectorAll('[slot="detail-outgoing"]').forEach((el) => el.remove());
645
+ this.$.outgoing.style.width = '';
809
646
  this.__replacing = false;
810
647
  }
811
648
 
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-alpha4",
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-alpha4",
4
+ "version": "25.2.0-alpha6",
5
5
  "description-markup": "markdown",
6
6
  "framework": "lit",
7
7
  "framework-config": {