@vaadin/master-detail-layout 25.1.0 → 25.2.0-alpha2

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.
@@ -8,36 +8,69 @@ import { getFocusableElements } from '@vaadin/a11y-base/src/focus-utils.js';
8
8
  import { defineCustomElement } from '@vaadin/component-base/src/define.js';
9
9
  import { ElementMixin } from '@vaadin/component-base/src/element-mixin.js';
10
10
  import { PolylitMixin } from '@vaadin/component-base/src/polylit-mixin.js';
11
- import { ResizeMixin } from '@vaadin/component-base/src/resize-mixin.js';
12
- import { SlotStylesMixin } from '@vaadin/component-base/src/slot-styles-mixin.js';
13
11
  import { ThemableMixin } from '@vaadin/vaadin-themable-mixin/vaadin-themable-mixin.js';
14
12
  import { masterDetailLayoutStyles } from './styles/vaadin-master-detail-layout-base-styles.js';
15
- import { masterDetailLayoutTransitionStyles } from './styles/vaadin-master-detail-layout-transition-base-styles.js';
13
+
14
+ function parseTrackSizes(gridTemplate) {
15
+ return gridTemplate
16
+ .replace(/\[[^\]]+\]/gu, '')
17
+ .replace(/\s+/gu, ' ')
18
+ .trim()
19
+ .split(' ')
20
+ .map(parseFloat);
21
+ }
16
22
 
17
23
  /**
18
24
  * `<vaadin-master-detail-layout>` is a web component for building UIs with a master
19
25
  * (or primary) area and a detail (or secondary) area that is displayed next to, or
20
26
  * overlaid on top of, the master area, depending on configuration and viewport size.
21
27
  *
28
+ * ### Slots
29
+ *
30
+ * The component has two main content areas: the master area (default slot)
31
+ * and the detail area (`detail` slot). When the detail doesn't fit next to
32
+ * the master, it is shown as an overlay on top of the master area:
33
+ *
34
+ * ```html
35
+ * <vaadin-master-detail-layout>
36
+ * <div>Master content</div>
37
+ * <div slot="detail">Detail content</div>
38
+ * </vaadin-master-detail-layout>
39
+ * ```
40
+ *
41
+ * The component also supports a `detail-placeholder` slot for content shown
42
+ * in the detail area when no detail is selected. Unlike the `detail` slot,
43
+ * the placeholder is simply hidden when it doesn't fit next to the master area,
44
+ * rather than shown as an overlay:
45
+ *
46
+ * ```html
47
+ * <vaadin-master-detail-layout>
48
+ * <div>Master content</div>
49
+ * <div slot="detail-placeholder">Select an item</div>
50
+ * </vaadin-master-detail-layout>
51
+ * ```
52
+ *
22
53
  * ### Styling
23
54
  *
24
55
  * The following shadow DOM parts are available for styling:
25
56
  *
26
- * Part name | Description
27
- * ---------------|----------------------
28
- * `backdrop` | Backdrop covering the master area in the drawer mode
29
- * `master` | The master area
30
- * `detail` | The detail area
57
+ * Part name | Description
58
+ * ----------------------|----------------------
59
+ * `backdrop` | Backdrop covering the master area in the overlay mode
60
+ * `master` | The master area
61
+ * `detail` | The detail area
62
+ * `detail-placeholder` | The detail placeholder area
31
63
  *
32
64
  * The following state attributes are available for styling:
33
65
  *
34
- * Attribute | Description
35
- * ---------------| -----------
36
- * `containment` | Set to `layout` or `viewport` depending on the containment.
37
- * `orientation` | Set to `horizontal` or `vertical` depending on the orientation.
38
- * `has-detail` | Set when the detail content is provided.
39
- * `drawer` | Set when the layout is using the drawer mode.
40
- * `stack` | Set when the layout is using the stack mode.
66
+ * Attribute | Description
67
+ * --------------------------|----------------------
68
+ * `expand` | Set to `master`, `detail`, or `both`.
69
+ * `orientation` | Set to `horizontal` or `vertical` depending on the orientation.
70
+ * `has-detail` | Set when the detail content is provided and visible.
71
+ * `has-detail-placeholder` | Set when the detail placeholder content is provided.
72
+ * `overlay` | Set when columns don't fit and the detail is shown as an overlay.
73
+ * `overlay-containment` | Set to `layout` or `viewport`.
41
74
  *
42
75
  * The following custom CSS properties are available for styling:
43
76
  *
@@ -51,17 +84,15 @@ import { masterDetailLayoutTransitionStyles } from './styles/vaadin-master-detai
51
84
  *
52
85
  * See [Styling Components](https://vaadin.com/docs/latest/styling/styling-components) documentation.
53
86
  *
54
- * @fires {CustomEvent} backdrop-click - Fired when the user clicks the backdrop in the drawer mode.
87
+ * @fires {CustomEvent} backdrop-click - Fired when the user clicks the backdrop in the overlay mode.
55
88
  * @fires {CustomEvent} detail-escape-press - Fired when the user presses Escape in the detail area.
56
89
  *
57
90
  * @customElement vaadin-master-detail-layout
58
91
  * @extends HTMLElement
59
92
  * @mixes ThemableMixin
60
93
  * @mixes ElementMixin
61
- * @mixes ResizeMixin
62
- * @mixes SlotStylesMixin
63
94
  */
64
- class MasterDetailLayout extends SlotStylesMixin(ResizeMixin(ElementMixin(ThemableMixin(PolylitMixin(LitElement))))) {
95
+ class MasterDetailLayout extends ElementMixin(ThemableMixin(PolylitMixin(LitElement))) {
65
96
  static get is() {
66
97
  return 'vaadin-master-detail-layout';
67
98
  }
@@ -73,11 +104,10 @@ class MasterDetailLayout extends SlotStylesMixin(ResizeMixin(ElementMixin(Themab
73
104
  static get properties() {
74
105
  return {
75
106
  /**
76
- * Fixed size (in CSS length units) to be set on the detail area.
77
- * When specified, it prevents the detail area from growing or
78
- * shrinking. If there is not enough space to show master and detail
79
- * areas next to each other, the details are shown as an overlay:
80
- * either as drawer or stack, depending on the `stackOverlay` property.
107
+ * Size (in CSS length units) to be set on the detail area in
108
+ * the CSS grid layout. If there is not enough space to show
109
+ * master and detail areas next to each other, the detail area
110
+ * is shown as an overlay. Defaults to 15em.
81
111
  *
82
112
  * @attr {string} detail-size
83
113
  */
@@ -88,26 +118,10 @@ class MasterDetailLayout extends SlotStylesMixin(ResizeMixin(ElementMixin(Themab
88
118
  },
89
119
 
90
120
  /**
91
- * Minimum size (in CSS length units) to be set on the detail area.
92
- * When specified, it prevents the detail area from shrinking below
93
- * this size. If there is not enough space to show master and detail
94
- * areas next to each other, the details are shown as an overlay:
95
- * either as drawer or stack, depending on the `stackOverlay` property.
96
- *
97
- * @attr {string} detail-min-size
98
- */
99
- detailMinSize: {
100
- type: String,
101
- sync: true,
102
- observer: '__detailMinSizeChanged',
103
- },
104
-
105
- /**
106
- * Fixed size (in CSS length units) to be set on the master area.
107
- * When specified, it prevents the master area from growing or
108
- * shrinking. If there is not enough space to show master and detail
109
- * areas next to each other, the details are shown as an overlay:
110
- * either as drawer or stack, depending on the `stackOverlay` property.
121
+ * Size (in CSS length units) to be set on the master area in
122
+ * the CSS grid layout. If there is not enough space to show
123
+ * master and detail areas next to each other, the detail area
124
+ * is shown as an overlay. Defaults to 30em.
111
125
  *
112
126
  * @attr {string} master-size
113
127
  */
@@ -118,18 +132,16 @@ class MasterDetailLayout extends SlotStylesMixin(ResizeMixin(ElementMixin(Themab
118
132
  },
119
133
 
120
134
  /**
121
- * Minimum size (in CSS length units) to be set on the master area.
122
- * When specified, it prevents the master area from shrinking below
123
- * this size. If there is not enough space to show master and detail
124
- * areas next to each other, the details are shown as an overlay:
125
- * either as drawer or stack, depending on the `stackOverlay` property.
135
+ * Size (in CSS length units) for the detail area when shown as an
136
+ * overlay. When not set, falls back to `detailSize`. Set to `100%`
137
+ * to make the detail cover the full layout.
126
138
  *
127
- * @attr {string} master-min-size
139
+ * @attr {string} overlay-size
128
140
  */
129
- masterMinSize: {
141
+ overlaySize: {
130
142
  type: String,
131
143
  sync: true,
132
- observer: '__masterMinSizeChanged',
144
+ observer: '__overlaySizeChanged',
133
145
  },
134
146
 
135
147
  /**
@@ -142,25 +154,6 @@ class MasterDetailLayout extends SlotStylesMixin(ResizeMixin(ElementMixin(Themab
142
154
  type: String,
143
155
  value: 'horizontal',
144
156
  reflectToAttribute: true,
145
- observer: '__orientationChanged',
146
- sync: true,
147
- },
148
-
149
- /**
150
- * When specified, forces the details to be shown as an overlay
151
- * (either as drawer or stack), even if there is enough space for
152
- * master and detail to be shown next to each other using the default
153
- * (split) mode.
154
- *
155
- * In order to enforce the stack mode, use this property together with
156
- * `stackOverlay` property and set both to `true`.
157
- *
158
- * @attr {boolean} force-overlay
159
- */
160
- forceOverlay: {
161
- type: Boolean,
162
- value: false,
163
- observer: '__forceOverlayChanged',
164
157
  sync: true,
165
158
  },
166
159
 
@@ -169,8 +162,10 @@ class MasterDetailLayout extends SlotStylesMixin(ResizeMixin(ElementMixin(Themab
169
162
  * overlay mode. When set to `layout`, the overlay is confined to the
170
163
  * layout. When set to `viewport`, the overlay is confined to the
171
164
  * browser's viewport. Defaults to `layout`.
165
+ *
166
+ * @attr {string} overlay-containment
172
167
  */
173
- containment: {
168
+ overlayContainment: {
174
169
  type: String,
175
170
  value: 'layout',
176
171
  reflectToAttribute: true,
@@ -178,19 +173,14 @@ class MasterDetailLayout extends SlotStylesMixin(ResizeMixin(ElementMixin(Themab
178
173
  },
179
174
 
180
175
  /**
181
- * When true, the layout in the overlay mode is rendered as a stack,
182
- * making detail area fully cover the master area. Otherwise, it is
183
- * rendered as a drawer and has a visual backdrop.
184
- *
185
- * In order to enforce the stack mode, use this property together with
186
- * `forceOverlay` property and set both to `true`.
187
- *
188
- * @attr {string} stack-threshold
176
+ * Controls which column(s) expand to fill available space.
177
+ * Possible values: `'master'`, `'detail'`, `'both'`.
178
+ * Defaults to `'master'`.
189
179
  */
190
- stackOverlay: {
191
- type: Boolean,
192
- value: false,
193
- observer: '__stackOverlayChanged',
180
+ expand: {
181
+ type: String,
182
+ value: 'master',
183
+ reflectToAttribute: true,
194
184
  sync: true,
195
185
  },
196
186
 
@@ -202,38 +192,12 @@ class MasterDetailLayout extends SlotStylesMixin(ResizeMixin(ElementMixin(Themab
202
192
  noAnimation: {
203
193
  type: Boolean,
204
194
  value: false,
205
- },
206
-
207
- /**
208
- * When true, the component uses the drawer mode. This property is read-only.
209
- * @protected
210
- */
211
- _drawer: {
212
- type: Boolean,
213
- attribute: 'drawer',
214
- reflectToAttribute: true,
215
- sync: true,
216
- },
217
-
218
- /**
219
- * When true, the component uses the stack mode. This property is read-only.
220
- * @protected
221
- */
222
- _stack: {
223
- type: Boolean,
224
- attribute: 'stack',
225
195
  reflectToAttribute: true,
226
- sync: true,
227
196
  },
228
197
 
229
- /**
230
- * When true, the component has the detail content provided.
231
- * @protected
232
- */
233
- _hasDetail: {
198
+ /** @private */
199
+ __replacing: {
234
200
  type: Boolean,
235
- attribute: 'has-detail',
236
- reflectToAttribute: true,
237
201
  sync: true,
238
202
  },
239
203
  };
@@ -243,211 +207,191 @@ class MasterDetailLayout extends SlotStylesMixin(ResizeMixin(ElementMixin(Themab
243
207
  return true;
244
208
  }
245
209
 
246
- /** @override */
247
- get slotStyles() {
248
- return [masterDetailLayoutTransitionStyles];
249
- }
250
-
251
210
  /** @protected */
252
211
  render() {
212
+ const isOverlay = this.hasAttribute('has-detail') && this.hasAttribute('overlay');
213
+ const isViewport = isOverlay && this.overlayContainment === 'viewport';
214
+ const isLayoutContained = isOverlay && !isViewport;
215
+
253
216
  return html`
254
- <div part="backdrop" @click="${this.__onBackdropClick}"></div>
217
+ <div id="backdrop" part="backdrop" @click="${this.__onBackdropClick}"></div>
218
+ <div id="master" part="master" ?inert="${isLayoutContained}">
219
+ <slot @slotchange="${this.__onSlotChange}"></slot>
220
+ </div>
221
+ <div id="outgoing" inert ?hidden="${!this.__replacing}">
222
+ <slot name="detail-outgoing"></slot>
223
+ </div>
255
224
  <div
256
- id="master"
257
- part="master"
258
- ?inert="${this._hasDetail && (this._stack || (this._drawer && this.containment === 'layout'))}"
225
+ id="detail"
226
+ part="detail"
227
+ role="${isOverlay ? 'dialog' : nothing}"
228
+ aria-modal="${isViewport ? 'true' : nothing}"
229
+ @keydown="${this.__onDetailKeydown}"
259
230
  >
260
- <slot></slot>
231
+ <slot name="detail" @slotchange="${this.__onSlotChange}"></slot>
261
232
  </div>
262
- <div part="_detail-internal">
263
- <div
264
- id="detail"
265
- part="detail"
266
- role="${this._drawer || this._stack ? 'dialog' : nothing}"
267
- aria-modal="${this._drawer && this.containment === 'viewport' ? 'true' : nothing}"
268
- @keydown="${this.__onDetailKeydown}"
269
- >
270
- <slot name="detail" @slotchange="${this.__onDetailSlotChange}"></slot>
271
- </div>
233
+ <div id="detail-placeholder" part="detail-placeholder">
234
+ <slot name="detail-placeholder" @slotchange="${this.__onSlotChange}"></slot>
272
235
  </div>
273
236
  `;
274
237
  }
275
238
 
276
- /** @private */
277
- __onDetailSlotChange(e) {
278
- const children = e.target.assignedNodes();
279
-
280
- this._hasDetail = children.length > 0;
281
- this.__detectLayoutMode();
282
-
283
- // Move focus to the detail area when it is added to the DOM,
284
- // in case if the layout is using drawer or stack mode.
285
- if ((this._drawer || this._stack) && children.length > 0) {
286
- const focusables = getFocusableElements(children[0]);
287
- if (focusables.length) {
288
- focusables[0].focus();
289
- }
290
- }
239
+ /** @protected */
240
+ connectedCallback() {
241
+ super.connectedCallback();
242
+ this.__initResizeObserver();
291
243
  }
292
244
 
293
- /** @private */
294
- __onBackdropClick() {
295
- this.dispatchEvent(new CustomEvent('backdrop-click'));
245
+ /** @protected */
246
+ disconnectedCallback() {
247
+ super.disconnectedCallback();
248
+ this.__resizeObserver.disconnect();
249
+ cancelAnimationFrame(this.__resizeRaf);
250
+ this.__endTransition();
296
251
  }
297
252
 
298
253
  /** @private */
299
- __onDetailKeydown(event) {
300
- if (event.key === 'Escape' && !event.defaultPrevented) {
301
- // Prevent firing on parent layout when using nested layouts
302
- event.preventDefault();
303
- this.dispatchEvent(new CustomEvent('detail-escape-press'));
304
- }
305
- }
306
-
307
- /**
308
- * @protected
309
- * @override
310
- */
311
- _onResize() {
312
- this.__detectLayoutMode();
254
+ __masterSizeChanged(size, oldSize) {
255
+ this.__updateStyleProperty('master-size', size, oldSize);
313
256
  }
314
257
 
315
258
  /** @private */
316
259
  __detailSizeChanged(size, oldSize) {
317
260
  this.__updateStyleProperty('detail-size', size, oldSize);
318
- this.__detectLayoutMode();
319
261
  }
320
262
 
321
263
  /** @private */
322
- __detailMinSizeChanged(size, oldSize) {
323
- this.__updateStyleProperty('detail-min-size', size, oldSize);
324
- this.__detectLayoutMode();
264
+ __overlaySizeChanged(size, oldSize) {
265
+ this.__updateStyleProperty('overlay-size', size, oldSize);
325
266
  }
326
267
 
327
268
  /** @private */
328
- __masterSizeChanged(size, oldSize) {
329
- this.__updateStyleProperty('master-size', size, oldSize);
330
- this.__detectLayoutMode();
269
+ __updateStyleProperty(prop, size, oldSize) {
270
+ if (size) {
271
+ this.style.setProperty(`--_${prop}`, size);
272
+ } else if (oldSize) {
273
+ this.style.removeProperty(`--_${prop}`);
274
+ }
331
275
  }
332
276
 
333
277
  /** @private */
334
- __masterMinSizeChanged(size, oldSize) {
335
- this.__updateStyleProperty('master-min-size', size, oldSize);
336
- this.__detectLayoutMode();
278
+ __onSlotChange() {
279
+ this.__initResizeObserver();
337
280
  }
338
281
 
339
282
  /** @private */
340
- __orientationChanged(orientation, oldOrientation) {
341
- if (orientation || oldOrientation) {
342
- this.__detectLayoutMode();
343
- }
283
+ __initResizeObserver() {
284
+ this.__resizeObserver = this.__resizeObserver || new ResizeObserver(() => this.__onResize());
285
+ this.__resizeObserver.disconnect();
286
+
287
+ const children = this.querySelectorAll(':scope > [slot="detail"], :scope >:not([slot])');
288
+ [this, this.$.master, this.$.detail, ...children].forEach((node) => {
289
+ this.__resizeObserver.observe(node);
290
+ });
344
291
  }
345
292
 
346
- /** @private */
347
- __forceOverlayChanged(forceOverlay, oldForceOverlay) {
348
- if (forceOverlay || oldForceOverlay) {
349
- this.__detectLayoutMode();
350
- }
293
+ /**
294
+ * Called by the ResizeObserver. Reads layout state synchronously (no forced
295
+ * reflow since layout is already computed), then defers writes to rAF.
296
+ * Cancels any pending rAF so the write phase always uses the latest state.
297
+ * @private
298
+ */
299
+ __onResize() {
300
+ const state = this.__computeLayoutState();
301
+ cancelAnimationFrame(this.__resizeRaf);
302
+ this.__resizeRaf = requestAnimationFrame(() => this.__applyLayoutState(state));
351
303
  }
352
304
 
353
- /** @private */
354
- __stackOverlayChanged(stackOverlay, oldStackOverlay) {
355
- if (stackOverlay || oldStackOverlay) {
356
- this.__detectLayoutMode();
357
- }
305
+ /**
306
+ * Reads DOM/style state needed for layout detection. Safe to call in
307
+ * ResizeObserver callback where layout is already computed (no forced reflow).
308
+ * @private
309
+ */
310
+ __computeLayoutState() {
311
+ const detailContent = this.querySelector(':scope > [slot="detail"]');
312
+ const detailPlaceholder = this.querySelector(':scope > [slot="detail-placeholder"]');
313
+
314
+ const hadDetail = this.hasAttribute('has-detail');
315
+ const hasDetail = detailContent != null && detailContent.checkVisibility();
316
+ const hasDetailPlaceholder = !!detailPlaceholder;
317
+ const hasOverflow = (hasDetail || hasDetailPlaceholder) && this.__checkOverflow();
318
+
319
+ const focusTarget = !hadDetail && hasDetail && hasOverflow ? getFocusableElements(detailContent)[0] : null;
320
+ return { hadDetail, hasDetail, hasDetailPlaceholder, hasOverflow, focusTarget };
358
321
  }
359
322
 
360
- /** @private */
361
- __updateStyleProperty(prop, size, oldSize) {
362
- if (size) {
363
- this.style.setProperty(`--_${prop}`, size);
364
- } else if (oldSize) {
365
- this.style.removeProperty(`--_${prop}`);
323
+ /**
324
+ * Applies layout state to DOM attributes. Pure writes, no reads.
325
+ * @private
326
+ */
327
+ __applyLayoutState({ hadDetail, hasDetail, hasDetailPlaceholder, hasOverflow, focusTarget }) {
328
+ // Set keep-detail-column-offscreen when detail first appears with overlay
329
+ // to prevent master width from jumping.
330
+ if (!hadDetail && hasDetail && hasOverflow) {
331
+ this.setAttribute('keep-detail-column-offscreen', '');
332
+ } else if (!hasDetail || !hasOverflow) {
333
+ this.removeAttribute('keep-detail-column-offscreen');
366
334
  }
367
335
 
368
- this.toggleAttribute(`has-${prop}`, !!size);
369
- }
336
+ this.toggleAttribute('overlay', hasOverflow);
337
+ this.toggleAttribute('has-detail', hasDetail);
338
+ this.toggleAttribute('has-detail-placeholder', hasDetailPlaceholder);
370
339
 
371
- /** @private */
372
- __setOverlayMode(value) {
373
- if (this.stackOverlay) {
374
- this._stack = value;
375
- } else {
376
- this._drawer = value;
340
+ // Re-render to update ARIA attributes (role, aria-modal, inert)
341
+ // which depend on has-detail and overlay state.
342
+ this.requestUpdate();
343
+
344
+ if (focusTarget) {
345
+ focusTarget.focus({ preventScroll: true });
377
346
  }
378
347
  }
379
348
 
380
349
  /** @private */
381
- __detectLayoutMode() {
382
- this._drawer = false;
383
- this._stack = false;
350
+ __checkOverflow() {
351
+ const isVertical = this.orientation === 'vertical';
352
+ const computedStyle = getComputedStyle(this);
384
353
 
385
- if (this.forceOverlay) {
386
- this.__setOverlayMode(true);
387
- return;
388
- }
354
+ const hostSize = parseFloat(computedStyle[isVertical ? 'height' : 'width']);
355
+ const [masterSize, masterExtra, detailSize] = parseTrackSizes(
356
+ computedStyle[isVertical ? 'gridTemplateRows' : 'gridTemplateColumns'],
357
+ );
389
358
 
390
- if (!this._hasDetail) {
391
- return;
359
+ if (Math.floor(masterSize + masterExtra + detailSize) <= Math.floor(hostSize)) {
360
+ return false;
392
361
  }
393
-
394
- if (this.orientation === 'vertical') {
395
- this.__detectVerticalMode();
396
- } else {
397
- this.__detectHorizontalMode();
362
+ if (Math.floor(masterExtra) >= Math.floor(detailSize)) {
363
+ return false;
398
364
  }
365
+ return true;
399
366
  }
400
367
 
401
368
  /** @private */
402
- __detectHorizontalMode() {
403
- const detailWidth = this.$.detail.offsetWidth;
404
-
405
- // Detect minimum width needed by master content. Use max-width to ensure
406
- // the layout can switch back to split mode once there is enough space.
407
- // If there is master size or min-size set, use that instead to force the
408
- // overlay mode by setting `masterSize` / `masterMinSize` to 100%/
409
- this.$.master.style.maxWidth = this.masterSize || this.masterMinSize || 'min-content';
410
- const masterWidth = this.$.master.offsetWidth;
411
- this.$.master.style.maxWidth = '';
412
-
413
- // If the combined minimum size of both the master and the detail content
414
- // exceeds the size of the layout, the layout changes to the overlay mode.
415
- this.__setOverlayMode(this.offsetWidth < masterWidth + detailWidth);
416
-
417
- // Toggling the overlay resizes master content, which can cause document
418
- // scroll bar to appear or disappear, and trigger another resize of the
419
- // layout which can affect previous measurements and end up in horizontal
420
- // scroll. Check if that is the case and if so, preserve the overlay mode.
421
- if (this.offsetWidth < this.scrollWidth) {
422
- this.__setOverlayMode(true);
423
- }
369
+ __onBackdropClick() {
370
+ this.dispatchEvent(new CustomEvent('backdrop-click'));
424
371
  }
425
372
 
426
373
  /** @private */
427
- __detectVerticalMode() {
428
- const masterHeight = this.$.master.clientHeight;
429
-
430
- // If the combined minimum size of both the master and the detail content
431
- // exceeds the available height, the layout changes to the overlay mode.
432
- if (this.offsetHeight < masterHeight + this.$.detail.clientHeight) {
433
- this.__setOverlayMode(true);
374
+ __onDetailKeydown(event) {
375
+ if (event.key === 'Escape' && !event.defaultPrevented) {
376
+ // Prevent firing on parent layout when using nested layouts
377
+ event.preventDefault();
378
+ this.dispatchEvent(new CustomEvent('detail-escape-press'));
434
379
  }
435
380
  }
436
381
 
437
382
  /**
438
- * Sets the detail element to be displayed in the detail area and starts a
439
- * view transition that animates adding, replacing or removing the detail
440
- * area. During the view transition, the element is added to the DOM and
441
- * assigned to the `detail` slot. Any previous detail element is removed.
442
- * When passing null as the element, the current detail element is removed.
383
+ * Sets the detail element to be displayed in the detail area and starts an
384
+ * animated transition for adding, replacing or removing the detail area.
385
+ * The element is added to the DOM and assigned to the `detail` slot. Any
386
+ * previous detail element is removed. When passing null as the element,
387
+ * the current detail element is removed.
443
388
  *
444
- * If the browser does not support view transitions, the respective updates
445
- * are applied immediately without starting a transition. The transition can
446
- * also be skipped using the `skipTransition` parameter.
389
+ * The transition can be skipped using the `skipTransition` parameter or
390
+ * the `noAnimation` property.
447
391
  *
448
392
  * @param element the new detail element, or null to remove the current detail
449
393
  * @param skipTransition whether to skip the transition
450
- * @returns {Promise<void>}
394
+ * @return {Promise<void>}
451
395
  * @protected
452
396
  */
453
397
  _setDetail(element, skipTransition) {
@@ -467,13 +411,17 @@ class MasterDetailLayout extends SlotStylesMixin(ResizeMixin(ElementMixin(Themab
467
411
  }
468
412
  };
469
413
 
470
- if (skipTransition) {
414
+ if (skipTransition || this.noAnimation) {
471
415
  updateSlot();
416
+ queueMicrotask(() => {
417
+ const state = this.__computeLayoutState();
418
+ this.__applyLayoutState(state);
419
+ });
472
420
  return Promise.resolve();
473
421
  }
474
422
 
475
- const hasDetail = !!currentDetail;
476
- const transitionType = hasDetail && element ? 'replace' : hasDetail ? 'remove' : 'add';
423
+ const transitionType = this.__getTransitionType(currentDetail, element);
424
+
477
425
  return this._startTransition(transitionType, () => {
478
426
  // Update the DOM
479
427
  updateSlot();
@@ -483,71 +431,278 @@ class MasterDetailLayout extends SlotStylesMixin(ResizeMixin(ElementMixin(Themab
483
431
  }
484
432
 
485
433
  /**
486
- * Starts a view transition that animates adding, replacing or removing the
487
- * detail area. Once the transition is ready and the browser has taken a
488
- * snapshot of the current layout, the provided update callback is called.
489
- * The callback should update the DOM, which can happen asynchronously.
490
- * Once the DOM is updated, the caller must call `_finishTransition`,
491
- * which results in the browser taking a snapshot of the new layout and
492
- * animating the transition.
434
+ * Determines the transition type for a detail change.
435
+ *
436
+ * Returns 'replace' in two cases:
437
+ * - Swapping one detail for another (standard replace).
438
+ * - Swapping between placeholder and detail in split mode,
439
+ * so the swap appears instant (replace has 0ms duration in split).
440
+ * In overlay mode, placeholder doesn't participate in transitions,
441
+ * so standard 'add'/'remove' are used instead.
442
+ *
443
+ * @param {Element | null} currentDetail
444
+ * @param {Element | null} newDetail
445
+ * @return {string}
446
+ * @private
447
+ */
448
+ __getTransitionType(currentDetail, newDetail) {
449
+ if (currentDetail && newDetail) {
450
+ return 'replace';
451
+ }
452
+
453
+ const hasPlaceholder = !!this.querySelector('[slot="detail-placeholder"]');
454
+ if (hasPlaceholder && !this.hasAttribute('overlay')) {
455
+ return 'replace';
456
+ }
457
+
458
+ return currentDetail ? 'remove' : 'add';
459
+ }
460
+
461
+ /**
462
+ * Starts an animated transition for adding, replacing or removing the
463
+ * detail area using the Web Animations API.
493
464
  *
494
- * If the browser does not support view transitions, or the `noAnimation`
495
- * property is set, the update callback is called immediately without
496
- * starting a transition.
465
+ * For 'remove', the DOM update is deferred until the slide-out completes.
466
+ * For 'add'/'replace', the DOM is updated immediately and the slide-in
467
+ * plays on the new content.
468
+ *
469
+ * Animations are interruptible: starting a new transition cancels any
470
+ * in-progress animation and the new animation picks up from the
471
+ * interrupted position (see `__captureDetailState`).
497
472
  *
498
473
  * @param transitionType
499
474
  * @param updateCallback
500
- * @returns {Promise<void>}
475
+ * @return {Promise<void>}
501
476
  * @protected
502
477
  */
503
478
  _startTransition(transitionType, updateCallback) {
504
- const useTransition = typeof document.startViewTransition === 'function' && !this.noAnimation;
505
- if (!useTransition) {
479
+ if (this.noAnimation) {
506
480
  updateCallback();
507
481
  return Promise.resolve();
508
482
  }
509
483
 
484
+ // Capture mid-flight state before cancelling active animations
485
+ const interrupted = this.__captureDetailState();
486
+
487
+ this.__endTransition();
488
+
489
+ if (transitionType === 'replace') {
490
+ this.__snapshotOutgoing();
491
+ }
492
+
510
493
  this.setAttribute('transition', transitionType);
511
- this.__transition = document.startViewTransition(() => {
512
- // Return a promise that can be resolved once the DOM is updated
513
- return new Promise((resolve) => {
514
- this.__resolveUpdateCallback = resolve;
515
- // Notify the caller that the transition is ready, so that they can
516
- // update the DOM
517
- updateCallback();
518
- });
494
+
495
+ if (transitionType !== 'remove') {
496
+ updateCallback();
497
+ }
498
+
499
+ const opts = this.__getAnimationParams();
500
+ opts.interrupted = interrupted;
501
+ opts.overlay = this.hasAttribute('overlay');
502
+
503
+ return this.__animateTransition(transitionType, opts, updateCallback);
504
+ }
505
+
506
+ /**
507
+ * Creates slide animation(s) for the given transition type and returns
508
+ * a promise that resolves when the primary animation completes.
509
+ * A version counter prevents stale callbacks from executing after
510
+ * a newer transition has started.
511
+ *
512
+ * @param {string} transitionType
513
+ * @param {{ offscreen: string, duration: number, easing: string, interrupted?: { translate: string, opacity: string }, overlay?: boolean }} opts
514
+ * @param {Function} updateCallback
515
+ * @return {Promise<void>}
516
+ * @private
517
+ */
518
+ __animateTransition(transitionType, opts, updateCallback) {
519
+ const version = (this.__transitionVersion = (this.__transitionVersion || 0) + 1);
520
+
521
+ return new Promise((resolve) => {
522
+ this.__transitionResolve = resolve;
523
+
524
+ const onFinish = (callback) => {
525
+ if (this.__transitionVersion === version) {
526
+ if (callback) {
527
+ callback();
528
+ }
529
+ this.__endTransition();
530
+ }
531
+ };
532
+
533
+ if (transitionType === 'remove') {
534
+ this.__slide(this.$.detail, false, opts).then(() => onFinish(updateCallback));
535
+ } else if (transitionType === 'replace') {
536
+ // Outgoing slides out on top (z-index), revealing incoming underneath.
537
+ // In overlay mode, the incoming also slides in simultaneously.
538
+ this.__slide(this.$.outgoing, false, opts).then(() => onFinish());
539
+ if (opts.overlay) {
540
+ this.__slide(this.$.detail, true, { ...opts, interrupted: null });
541
+ }
542
+ } else {
543
+ this.__slide(this.$.detail, true, opts).then(() => onFinish());
544
+ }
545
+
546
+ // Fade backdrop in/out for overlay add/remove (not replace — backdrop stays visible)
547
+ if (opts.overlay && transitionType !== 'replace') {
548
+ const fadeIn = transitionType !== 'remove';
549
+ this.__animate(this.$.backdrop, [{ opacity: fadeIn ? 0 : 1 }, { opacity: fadeIn ? 1 : 0 }], {
550
+ duration: opts.duration,
551
+ easing: 'linear',
552
+ });
553
+ }
519
554
  });
520
- return this.__transition.finished;
521
555
  }
522
556
 
523
557
  /**
524
- * Finishes the current view transition, if any. This method should be called
525
- * after the DOM has been updated to finish the transition and animate the
526
- * change in the layout.
558
+ * Finishes the current transition by detecting and applying the layout
559
+ * state. This method should be called after the DOM has been updated.
527
560
  *
528
- * @returns {Promise<void>}
529
561
  * @protected
530
562
  */
531
- async _finishTransition() {
532
- // Detect new layout mode after DOM has been updated.
533
- // The detection is wrapped in queueMicroTask in order to allow custom Lit elements to render before measurement.
534
- // https://github.com/vaadin/web-components/issues/8969
535
- queueMicrotask(() => this.__detectLayoutMode());
563
+ _finishTransition() {
564
+ const state = this.__computeLayoutState();
565
+ this.__applyLayoutState(state);
566
+ }
567
+
568
+ /**
569
+ * Captures the detail panel's current animated state (translate and
570
+ * opacity). Must be called BEFORE `animation.cancel()`, because
571
+ * cancel removes the animation effect and the element reverts to
572
+ * its CSS resting state.
573
+ *
574
+ * Returns null when there is no active animation.
575
+ *
576
+ * @return {{ translate: string, opacity: string } | null}
577
+ * @private
578
+ */
579
+ __captureDetailState() {
580
+ if (!this.__activeAnimations || this.__activeAnimations.length === 0) {
581
+ return null;
582
+ }
583
+ const { translate, opacity } = getComputedStyle(this.$.detail);
584
+ return { translate, opacity };
585
+ }
586
+
587
+ /**
588
+ * Reads animation parameters from CSS custom properties. Called once
589
+ * per transition so that animating stays free of layout reads.
590
+ *
591
+ * @return {{ offscreen: string, duration: number, easing: string }}
592
+ * @private
593
+ */
594
+ __getAnimationParams() {
595
+ const cs = getComputedStyle(this);
596
+ const offscreen = cs.getPropertyValue('--_detail-offscreen').trim();
597
+ const durationStr = cs.getPropertyValue('--_transition-duration').trim();
598
+ const duration = durationStr.endsWith('ms') ? parseFloat(durationStr) : parseFloat(durationStr) * 1000;
599
+ const easing = cs.getPropertyValue('--_transition-easing').trim();
600
+ return { offscreen, duration, easing };
601
+ }
536
602
 
537
- if (!this.__transition) {
603
+ /**
604
+ * Creates a slide animation on the element's `translate` property
605
+ * using the Web Animations API. Returns a promise that resolves when
606
+ * the animation finishes, or immediately if the duration is 0.
607
+ *
608
+ * @param {HTMLElement} element - The element to animate
609
+ * @param {boolean} slideIn - If true, slide in (off-screen → on-screen);
610
+ * otherwise slide out (on-screen → off-screen)
611
+ * @param {{ offscreen: string, duration: number, easing: string, interrupted?: { translate: string, opacity: string }, overlay?: boolean }} opts
612
+ * Animation parameters. `interrupted` overrides the default starting
613
+ * keyframe for interrupted animations (captured mid-flight before cancel).
614
+ * @return {Promise<void>}
615
+ * @private
616
+ */
617
+ __slide(element, slideIn, { offscreen, duration, easing, interrupted, overlay }) {
618
+ if (!offscreen || duration <= 0) {
538
619
  return Promise.resolve();
539
620
  }
540
- // Resolve the update callback to finish the transition
541
- this.__resolveUpdateCallback();
542
- await this.__transition.finished;
621
+
622
+ const defaultTranslate = slideIn ? offscreen : 'none';
623
+ const defaultOpacity = !overlay && slideIn ? 0 : 1;
624
+
625
+ const start = interrupted ? interrupted.translate : defaultTranslate;
626
+ const end = slideIn ? 'none' : offscreen;
627
+
628
+ const opacityStart = interrupted ? Number(interrupted.opacity) : defaultOpacity;
629
+ const opacityEnd = !overlay && !slideIn ? 0 : 1;
630
+
631
+ return this.__animate(
632
+ element,
633
+ [
634
+ { translate: start, opacity: opacityStart },
635
+ { translate: end, opacity: opacityEnd },
636
+ ],
637
+ { duration, easing },
638
+ );
639
+ }
640
+
641
+ /**
642
+ * Runs a Web Animation on the given element, tracks it for cancellation,
643
+ * and returns a promise that resolves when finished (or swallows the
644
+ * rejection if cancelled).
645
+ *
646
+ * @param {HTMLElement} element
647
+ * @param {Keyframe[]} keyframes
648
+ * @param {KeyframeAnimationOptions} options
649
+ * @return {Promise<void>}
650
+ * @private
651
+ */
652
+ __animate(element, keyframes, options) {
653
+ const animation = element.animate(keyframes, options);
654
+
655
+ this.__activeAnimations = this.__activeAnimations || [];
656
+ this.__activeAnimations.push(animation);
657
+
658
+ return animation.finished.catch(() => {});
659
+ }
660
+
661
+ /**
662
+ * Cancels in-progress animations, cleans up state, and resolves the
663
+ * pending transition promise.
664
+ * @private
665
+ */
666
+ __endTransition() {
667
+ if (this.__activeAnimations) {
668
+ this.__activeAnimations.forEach((a) => a.cancel());
669
+ this.__activeAnimations = null;
670
+ }
543
671
  this.removeAttribute('transition');
544
- this.__transition = null;
545
- this.__resolveUpdateCallback = null;
672
+ this.__clearOutgoing();
673
+ if (this.__transitionResolve) {
674
+ this.__transitionResolve();
675
+ this.__transitionResolve = null;
676
+ }
677
+ }
678
+
679
+ /**
680
+ * Moves the current detail content to the outgoing slot so it can
681
+ * slide out while the new content slides in. Keeps the element in
682
+ * light DOM so light DOM styles continue to apply.
683
+ * @private
684
+ */
685
+ __snapshotOutgoing() {
686
+ const currentDetail = this.querySelector('[slot="detail"]');
687
+ if (!currentDetail) {
688
+ return;
689
+ }
690
+ currentDetail.setAttribute('slot', 'detail-outgoing');
691
+ this.__replacing = true;
692
+ }
693
+
694
+ /**
695
+ * Clears the outgoing container after the replace transition completes.
696
+ * @private
697
+ */
698
+ __clearOutgoing() {
699
+ this.querySelectorAll('[slot="detail-outgoing"]').forEach((el) => el.remove());
700
+ this.__replacing = false;
546
701
  }
547
702
 
548
703
  /**
549
704
  * @event backdrop-click
550
- * Fired when the user clicks the backdrop in the drawer mode.
705
+ * Fired when the user clicks the backdrop in the overlay mode.
551
706
  */
552
707
 
553
708
  /**