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

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-alpha7",
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-alpha7",
38
+ "@vaadin/component-base": "25.2.0-alpha7",
39
+ "@vaadin/vaadin-themable-mixin": "25.2.0-alpha7",
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-alpha7",
44
+ "@vaadin/chai-plugins": "25.2.0-alpha7",
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-alpha7",
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": "3f0862906d60205d107836d8eca84c6fde4a9129"
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:
@@ -51,17 +51,17 @@ export const masterDetailLayoutStyles = css`
51
51
  [detail-end];
52
52
  }
53
53
 
54
- :is(#master, #detail, #detail-placeholder, #outgoing) {
54
+ :is(#master, #detail, #detailPlaceholder, #detailOutgoing) {
55
55
  box-sizing: border-box;
56
56
  }
57
57
 
58
- #detail-placeholder {
58
+ #detailPlaceholder {
59
59
  z-index: 1;
60
60
  opacity: 0;
61
61
  pointer-events: none;
62
62
  }
63
63
 
64
- :host([has-detail-placeholder]:not([has-detail], [overlay])) #detail-placeholder {
64
+ :host([has-detail-placeholder]:not([has-detail], [overlay])) #detailPlaceholder {
65
65
  opacity: 1;
66
66
  pointer-events: auto;
67
67
  }
@@ -69,9 +69,16 @@ export const masterDetailLayoutStyles = css`
69
69
  #master {
70
70
  grid-column: master-start / detail-start;
71
71
  grid-row: 1;
72
+ opacity: 0;
73
+ pointer-events: none;
72
74
  }
73
75
 
74
- :is(#detail, #detail-placeholder, #outgoing) {
76
+ :host([has-master]) #master {
77
+ opacity: 1;
78
+ pointer-events: auto;
79
+ }
80
+
81
+ :is(#detail, #detailPlaceholder, #detailOutgoing) {
75
82
  grid-column: detail-start / detail-end;
76
83
  grid-row: 1;
77
84
  }
@@ -81,12 +88,14 @@ export const masterDetailLayoutStyles = css`
81
88
  grid-row: master-start / detail-start;
82
89
  }
83
90
 
84
- :host([orientation='vertical']) :is(#detail, #detail-placeholder, #outgoing) {
91
+ :host([orientation='vertical']) :is(#detail, #detailPlaceholder, #detailOutgoing) {
85
92
  grid-column: 1;
86
93
  grid-row: detail-start / detail-end;
87
94
  }
88
95
 
89
96
  #backdrop {
97
+ --_transition-easing: linear;
98
+
90
99
  position: absolute;
91
100
  inset: 0;
92
101
  z-index: 2;
@@ -111,31 +120,36 @@ export const masterDetailLayoutStyles = css`
111
120
  }
112
121
 
113
122
  :host([keep-detail-column-offscreen]),
114
- :host([has-detail-placeholder][overlay]),
123
+ :host([has-detail-placeholder][overlay]:not([has-detail])),
115
124
  :host(:not([has-detail-placeholder], [has-detail])) {
116
125
  --_master-extra: calc(100% - var(--_master-size));
117
126
  }
118
127
 
119
- :host([orientation='horizontal']) #detail-placeholder,
128
+ :host([orientation='horizontal']) #detailPlaceholder,
120
129
  :host([orientation='horizontal']:not([overlay])) #detail {
121
130
  border-inline-start: var(--vaadin-master-detail-layout-border-width, 1px) solid
122
131
  var(--vaadin-master-detail-layout-border-color, var(--vaadin-border-color-secondary));
123
132
  }
124
133
 
125
- :host([orientation='vertical']) #detail-placeholder,
134
+ :host([orientation='vertical']) #detailPlaceholder,
126
135
  :host([orientation='vertical']:not([overlay])) #detail {
127
136
  border-top: var(--vaadin-master-detail-layout-border-width, 1px) solid
128
137
  var(--vaadin-master-detail-layout-border-color, var(--vaadin-border-color-secondary));
129
138
  }
130
139
 
131
- #outgoing {
140
+ #detailOutgoing {
132
141
  position: absolute;
133
142
  z-index: 3;
143
+ display: none;
144
+ }
145
+
146
+ :host([transition='replace']) #detailOutgoing {
147
+ display: block;
134
148
  }
135
149
 
136
150
  /* Detail transition: off-screen by default, on-screen when has-detail */
137
151
  #detail {
138
- translate: var(--_detail-offscreen);
152
+ translate: var(--_transition-offset);
139
153
  opacity: 0;
140
154
  z-index: 4;
141
155
  }
@@ -146,14 +160,14 @@ export const masterDetailLayoutStyles = css`
146
160
  }
147
161
 
148
162
  :host([overlay]) {
149
- --_detail-offscreen: calc((100% + 30px) * var(--_rtl-multiplier));
163
+ --_transition-offset: calc((100% + 30px) * var(--_rtl-multiplier));
150
164
  }
151
165
 
152
166
  :host([overlay][orientation='vertical']) {
153
- --_detail-offscreen: 0 calc(100% + 30px);
167
+ --_transition-offset: 0 calc(100% + 30px);
154
168
  }
155
169
 
156
- :host([has-detail][overlay]) :is(#detail, #outgoing) {
170
+ :host([has-detail][overlay]) :is(#detail, #detailOutgoing) {
157
171
  position: absolute;
158
172
  background: var(--vaadin-master-detail-layout-detail-background, var(--vaadin-background-color));
159
173
  box-shadow: var(--vaadin-master-detail-layout-detail-shadow, 0 0 20px 0 rgba(0, 0, 0, 0.3));
@@ -166,30 +180,30 @@ export const masterDetailLayoutStyles = css`
166
180
  pointer-events: auto;
167
181
  }
168
182
 
169
- :host([has-detail][overlay]:not([orientation='vertical'])) :is(#detail, #outgoing) {
183
+ :host([has-detail][overlay]:not([orientation='vertical'])) :is(#detail, #detailOutgoing) {
170
184
  inset-block: 0;
171
185
  inset-inline-end: 0;
172
186
  width: var(--_overlay-size, var(--_detail-size));
173
187
  max-width: 100%;
174
188
  }
175
189
 
176
- :host([has-detail][overlay][orientation='vertical']) :is(#detail, #outgoing) {
190
+ :host([has-detail][overlay][orientation='vertical']) :is(#detail, #detailOutgoing) {
177
191
  inset-inline: 0;
178
192
  inset-block-end: 0;
179
193
  height: var(--_overlay-size, var(--_detail-size));
180
194
  max-height: 100%;
181
195
  }
182
196
 
183
- :host([has-detail][overlay][overlay-containment='viewport']) :is(#detail, #outgoing, #backdrop) {
197
+ :host([has-detail][overlay][overlay-containment='viewport']) :is(#detail, #detailOutgoing, #backdrop) {
184
198
  position: fixed;
185
199
  }
186
200
 
187
201
  @media (forced-colors: active) {
188
- :host([has-detail][overlay]) :is(#detail, #outgoing) {
202
+ :host([has-detail][overlay]) :is(#detail, #detailOutgoing) {
189
203
  outline: 3px solid !important;
190
204
  }
191
205
 
192
- :is(#detail, #detail-placeholder, #outgoing) {
206
+ :is(#detail, #detailPlaceholder, #detailOutgoing) {
193
207
  background: Canvas !important;
194
208
  }
195
209
  }
@@ -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
+ }
@@ -4,34 +4,21 @@
4
4
  * This program is available under Apache License Version 2.0, available at https://vaadin.com/license/
5
5
  */
6
6
  import { html, LitElement, nothing } from 'lit';
7
- import { getFocusableElements } from '@vaadin/a11y-base/src/focus-utils.js';
7
+ import { getFocusableElements, isKeyboardActive } from '@vaadin/a11y-base/src/focus-utils.js';
8
8
  import { defineCustomElement } from '@vaadin/component-base/src/define.js';
9
9
  import { getClosestElement } from '@vaadin/component-base/src/dom-utils.js';
10
10
  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
@@ -215,12 +202,6 @@ class MasterDetailLayout extends ElementMixin(ThemableMixin(PolylitMixin(LitElem
215
202
  reflectToAttribute: true,
216
203
  },
217
204
 
218
- /** @private */
219
- __replacing: {
220
- type: Boolean,
221
- sync: true,
222
- },
223
-
224
205
  /** @private */
225
206
  __detailCachedSize: {
226
207
  type: String,
@@ -245,7 +226,7 @@ class MasterDetailLayout extends ElementMixin(ThemableMixin(PolylitMixin(LitElem
245
226
  <div id="master" part="master" ?inert="${isLayoutContained}">
246
227
  <slot @slotchange="${this.__onSlotChange}"></slot>
247
228
  </div>
248
- <div id="outgoing" inert ?hidden="${!this.__replacing}">
229
+ <div id="detailOutgoing" inert>
249
230
  <slot name="detail-outgoing"></slot>
250
231
  </div>
251
232
  <div
@@ -257,7 +238,7 @@ class MasterDetailLayout extends ElementMixin(ThemableMixin(PolylitMixin(LitElem
257
238
  >
258
239
  <slot name="detail" @slotchange="${this.__onSlotChange}"></slot>
259
240
  </div>
260
- <div id="detail-placeholder" part="detail-placeholder">
241
+ <div id="detailPlaceholder" part="detail-placeholder">
261
242
  <slot name="detail-placeholder" @slotchange="${this.__onSlotChange}"></slot>
262
243
  </div>
263
244
  `;
@@ -267,6 +248,17 @@ class MasterDetailLayout extends ElementMixin(ThemableMixin(PolylitMixin(LitElem
267
248
  connectedCallback() {
268
249
  super.connectedCallback();
269
250
  this.__initResizeObserver();
251
+
252
+ const ancestorLayouts = this.__ancestorLayouts;
253
+ if (ancestorLayouts.length > 0) {
254
+ ancestorLayouts.forEach((layout) => {
255
+ cancelAnimationFrame(layout.__initialRaf);
256
+ });
257
+
258
+ this.__initialRaf = requestAnimationFrame(() => {
259
+ this.recalculateLayout();
260
+ });
261
+ }
270
262
  }
271
263
 
272
264
  /** @protected */
@@ -274,7 +266,8 @@ class MasterDetailLayout extends ElementMixin(ThemableMixin(PolylitMixin(LitElem
274
266
  super.disconnectedCallback();
275
267
  this.__resizeObserver.disconnect();
276
268
  cancelAnimationFrame(this.__resizeRaf);
277
- this.__endTransition();
269
+ cancelAnimationFrame(this.__initialRaf);
270
+ cancelAnimations(this);
278
271
  }
279
272
 
280
273
  /** @private */
@@ -331,9 +324,10 @@ class MasterDetailLayout extends ElementMixin(ThemableMixin(PolylitMixin(LitElem
331
324
  this.__resizeObserver = this.__resizeObserver || new ResizeObserver(() => this.__onResize());
332
325
  this.__resizeObserver.disconnect();
333
326
 
334
- const children = this.querySelectorAll(':scope > [slot="detail"], :scope >:not([slot])');
335
- [this, this.$.master, this.$.detail, ...children].forEach((node) => {
336
- this.__resizeObserver.observe(node);
327
+ [this, this.$.master, this.$.detail, this.__slottedMaster, this.__slottedDetail].forEach((node) => {
328
+ if (node) {
329
+ this.__resizeObserver.observe(node);
330
+ }
337
331
  });
338
332
  }
339
333
 
@@ -357,12 +351,14 @@ class MasterDetailLayout extends ElementMixin(ThemableMixin(PolylitMixin(LitElem
357
351
  __readLayoutState() {
358
352
  const isVertical = this.orientation === 'vertical';
359
353
 
360
- const detailContent = this.querySelector(':scope > [slot="detail"]');
361
- const detailPlaceholder = this.querySelector(':scope > [slot="detail-placeholder"]');
354
+ const slottedMaster = this.__slottedMaster;
355
+ const slottedDetail = this.__slottedDetail;
356
+ const slottedDetailPlaceholder = this.__slottedDetailPlaceholder;
362
357
 
358
+ const hasMaster = !!slottedMaster;
363
359
  const hadDetail = this.hasAttribute('has-detail');
364
- const hasDetail = detailContent != null && detailContent.checkVisibility();
365
- const hasDetailPlaceholder = !!detailPlaceholder;
360
+ const hasDetail = slottedDetail != null && slottedDetail.checkVisibility();
361
+ const hasDetailPlaceholder = !!slottedDetailPlaceholder;
366
362
 
367
363
  const computedStyle = getComputedStyle(this);
368
364
  const hostSizeProp = isVertical ? 'height' : 'width';
@@ -372,9 +368,10 @@ class MasterDetailLayout extends ElementMixin(ThemableMixin(PolylitMixin(LitElem
372
368
  const trackSizes = parseTrackSizes(computedStyle[trackSizesProp]);
373
369
 
374
370
  const hasOverflow = (hasDetail || hasDetailPlaceholder) && detectOverflow(hostSize, trackSizes);
375
- const focusTarget = !hadDetail && hasDetail && hasOverflow ? getFocusableElements(detailContent)[0] : null;
371
+ const focusTarget = !hadDetail && hasDetail && hasOverflow ? getFocusableElements(slottedDetail)[0] : null;
376
372
 
377
373
  return {
374
+ hasMaster,
378
375
  hadDetail,
379
376
  hasDetail,
380
377
  hasDetailPlaceholder,
@@ -389,7 +386,7 @@ class MasterDetailLayout extends ElementMixin(ThemableMixin(PolylitMixin(LitElem
389
386
  * Applies layout state to DOM attributes. Pure writes, no reads.
390
387
  * @private
391
388
  */
392
- __writeLayoutState({ hadDetail, hasDetail, hasDetailPlaceholder, hasOverflow, focusTarget, trackSizes }) {
389
+ __writeLayoutState({ hasMaster, hadDetail, hasDetail, hasDetailPlaceholder, hasOverflow, focusTarget, trackSizes }) {
393
390
  const [_masterSize, _masterExtra, detailSize] = trackSizes;
394
391
 
395
392
  // If no detailSize is explicitily set, cache the intrinsic size (min-content) of
@@ -411,6 +408,7 @@ class MasterDetailLayout extends ElementMixin(ThemableMixin(PolylitMixin(LitElem
411
408
  }
412
409
 
413
410
  this.toggleAttribute('overlay', hasOverflow);
411
+ this.toggleAttribute('has-master', hasMaster);
414
412
  this.toggleAttribute('has-detail', hasDetail);
415
413
  this.toggleAttribute('has-detail-placeholder', hasDetailPlaceholder);
416
414
 
@@ -419,7 +417,7 @@ class MasterDetailLayout extends ElementMixin(ThemableMixin(PolylitMixin(LitElem
419
417
  this.requestUpdate();
420
418
 
421
419
  if (focusTarget) {
422
- focusTarget.focus({ preventScroll: true });
420
+ focusTarget.focus({ preventScroll: true, focusVisible: isKeyboardActive() });
423
421
  }
424
422
  }
425
423
 
@@ -437,14 +435,14 @@ class MasterDetailLayout extends ElementMixin(ThemableMixin(PolylitMixin(LitElem
437
435
  * synchronous DOM reads and writes.
438
436
  */
439
437
  recalculateLayout() {
440
- // Cancel any pending ResizeObserver rAF to prevent it from potentially
441
- // overriding the layout state with stale measurements.
442
- cancelAnimationFrame(this.__resizeRaf);
443
-
444
- const invalidatedLayouts = [...this.__ancestorLayouts.filter((layout) => layout.__isDetailAutoSized), this];
438
+ const invalidatedLayouts = [...this.__ancestorLayouts, this];
445
439
 
446
440
  // Write
447
441
  invalidatedLayouts.forEach((layout) => {
442
+ // Cancel any pending ResizeObserver rAF to prevent it from potentially
443
+ // overriding the layout state with stale measurements.
444
+ cancelAnimationFrame(layout.__resizeRaf);
445
+
448
446
  layout.__detailCachedSize = null;
449
447
 
450
448
  if (layout.__isDetailAutoSized) {
@@ -507,65 +505,42 @@ class MasterDetailLayout extends ElementMixin(ThemableMixin(PolylitMixin(LitElem
507
505
  * @return {Promise<void>}
508
506
  * @protected
509
507
  */
510
- _setDetail(element, skipTransition) {
508
+ async _setDetail(newDetail, skipTransition) {
511
509
  // 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();
510
+ const oldDetail = this.__slottedDetail;
511
+ if (oldDetail === (newDetail || null)) {
512
+ return;
515
513
  }
516
514
 
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);
515
+ const updateSlot = async () => {
516
+ if (oldDetail && oldDetail.slot === 'detail') {
517
+ oldDetail.remove();
524
518
  }
525
- };
526
519
 
527
- if (skipTransition || this.noAnimation) {
528
- updateSlot();
529
- queueMicrotask(() => this.recalculateLayout());
530
- return Promise.resolve();
531
- }
520
+ if (newDetail) {
521
+ newDetail.setAttribute('slot', 'detail');
522
+ this.appendChild(newDetail);
523
+ }
532
524
 
533
- const transitionType = this.__getTransitionType(currentDetail, element);
525
+ // Wait for Lit elements to render
526
+ await Promise.resolve();
534
527
 
535
- return this._startTransition(transitionType, () => {
536
- // Update the DOM
537
- updateSlot();
538
- // Finish the transition
539
- this._finishTransition();
540
- });
541
- }
528
+ this.recalculateLayout();
529
+ };
542
530
 
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';
531
+ if (skipTransition || this.noAnimation) {
532
+ await updateSlot();
533
+ return;
561
534
  }
562
535
 
563
- const hasPlaceholder = !!this.querySelector('[slot="detail-placeholder"]');
564
- if (hasPlaceholder && !this.hasAttribute('overlay')) {
565
- return 'replace';
536
+ const hasPlaceholder = !!this.__slottedDetailPlaceholder;
537
+ if ((oldDetail && newDetail) || (hasPlaceholder && !this.hasAttribute('overlay'))) {
538
+ await this._startTransition('replace', updateSlot);
539
+ } else if (!oldDetail && newDetail) {
540
+ await this._startTransition('add', updateSlot);
541
+ } else if (oldDetail && !newDetail) {
542
+ await this._startTransition('remove', updateSlot);
566
543
  }
567
-
568
- return currentDetail ? 'remove' : 'add';
569
544
  }
570
545
 
571
546
  /**
@@ -583,230 +558,101 @@ class MasterDetailLayout extends ElementMixin(ThemableMixin(PolylitMixin(LitElem
583
558
  * and picks up from the interrupted position.
584
559
  *
585
560
  * @param transitionType
586
- * @param updateCallback
561
+ * @param updateSlot
587
562
  * @return {Promise<void>}
588
563
  * @protected
589
564
  */
590
- async _startTransition(transitionType, updateCallback) {
565
+ async _startTransition(transitionType, updateSlot) {
591
566
  if (this.noAnimation) {
592
- updateCallback();
567
+ await updateSlot();
593
568
  return;
594
569
  }
595
570
 
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 });
571
+ try {
572
+ this.setAttribute('transition', transitionType);
573
+
574
+ switch (transitionType) {
575
+ case 'add':
576
+ await this.__addTransition(updateSlot);
577
+ break;
578
+ case 'remove':
579
+ await this.__removeTransition(updateSlot);
580
+ break;
581
+ default:
582
+ await this.__replaceTransition(updateSlot);
583
+ break;
653
584
  }
654
- } else {
655
- slide = this.__slide(this.$.detail, true, opts);
656
- }
657
585
 
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
- });
586
+ this.removeAttribute('transition');
587
+ } catch (e) {
588
+ if (e instanceof DOMException && e.name === 'AbortError') {
589
+ return; // Animation was cancelled
590
+ }
591
+ throw e;
665
592
  }
666
-
667
- return slide;
668
593
  }
669
594
 
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
-
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;
694
- }
695
- const { translate, opacity } = getComputedStyle(this.$.detail);
696
- return { translate, opacity };
697
- }
595
+ /** @private */
596
+ async __addTransition(updateSlot) {
597
+ await updateSlot();
698
598
 
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 };
599
+ const progress = getCurrentAnimationProgress(this.$.detail);
600
+ await Promise.all([
601
+ animateIn(this.$.detail, ['fade', 'slide'], progress),
602
+ animateIn(this.$.backdrop, ['fade'], this.hasAttribute('overlay') ? progress : 1),
603
+ ]);
713
604
  }
714
605
 
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();
606
+ /** @private */
607
+ async __replaceTransition(updateSlot) {
608
+ const oldDetail = this.__slottedDetail;
609
+ if (oldDetail) {
610
+ oldDetail.slot = 'detail-outgoing';
732
611
  }
733
612
 
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;
742
-
743
- return this.__animate(
744
- element,
745
- [
746
- { translate: start, opacity: opacityStart },
747
- { translate: end, opacity: opacityEnd },
748
- ],
749
- { duration, easing },
750
- );
613
+ try {
614
+ this.$.detailOutgoing.style.width = this.__detailCachedSize;
615
+
616
+ await updateSlot();
617
+
618
+ const progress = getCurrentAnimationProgress(this.$.detail);
619
+ await Promise.all([
620
+ animateIn(this.$.detail, ['fade', 'slide'], progress),
621
+ animateOut(this.$.detailOutgoing, ['fade', 'slide'], progress),
622
+ ]);
623
+ } finally {
624
+ // Skip removal if the slot was reassigned during the transition.
625
+ // The React component does this to let React handle the removal.
626
+ if (oldDetail && oldDetail.slot === 'detail-outgoing') {
627
+ oldDetail.remove();
628
+ }
629
+ }
751
630
  }
752
631
 
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' });
766
-
767
- this.__activeAnimations = this.__activeAnimations || [];
768
- this.__activeAnimations.push(animation);
632
+ /** @private */
633
+ async __removeTransition(updateSlot) {
634
+ const progress = getCurrentAnimationProgress(this.$.detail);
635
+ await Promise.all([
636
+ animateOut(this.$.detail, ['fade', 'slide'], progress),
637
+ animateOut(this.$.backdrop, ['fade'], this.hasAttribute('overlay') ? progress : 1),
638
+ ]);
769
639
 
770
- return animation.finished.catch(() => {});
640
+ await updateSlot();
771
641
  }
772
642
 
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();
643
+ /** @private */
644
+ get __slottedMaster() {
645
+ return this.querySelector(':scope > :is([slot=""], :not([slot]))');
784
646
  }
785
647
 
786
- /**
787
- * Moves the current detail content to the outgoing slot so it can
788
- * slide out while the new content slides in. Keeps the element in
789
- * light DOM so light DOM styles continue to apply.
790
- * @private
791
- */
792
- __snapshotOutgoing() {
793
- const currentDetail = this.querySelector('[slot="detail"]');
794
- if (!currentDetail) {
795
- return;
796
- }
797
- currentDetail.setAttribute('slot', 'detail-outgoing');
798
- this.$.outgoing.style.width = this.__detailCachedSize;
799
- this.__replacing = true;
648
+ /** @private */
649
+ get __slottedDetail() {
650
+ return this.querySelector(':scope > [slot="detail"]');
800
651
  }
801
652
 
802
- /**
803
- * Clears the outgoing container after the replace transition completes.
804
- * @private
805
- */
806
- __clearOutgoing() {
807
- this.querySelectorAll('[slot="detail-outgoing"]').forEach((el) => el.remove());
808
- this.$.outgoing.style.width = '';
809
- this.__replacing = false;
653
+ /** @private */
654
+ get __slottedDetailPlaceholder() {
655
+ return this.querySelector(':scope > [slot="detail-placeholder"]');
810
656
  }
811
657
 
812
658
  /**
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-alpha7",
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-alpha7",
5
5
  "description-markup": "markdown",
6
6
  "framework": "lit",
7
7
  "framework-config": {