@vaadin/master-detail-layout 25.2.0-alpha1 → 25.2.0-alpha11

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.
@@ -4,48 +4,74 @@
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
+ import { getClosestElement } from '@vaadin/component-base/src/dom-utils.js';
9
10
  import { ElementMixin } from '@vaadin/component-base/src/element-mixin.js';
10
11
  import { PolylitMixin } from '@vaadin/component-base/src/polylit-mixin.js';
11
- import { SlotStylesMixin } from '@vaadin/component-base/src/slot-styles-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
- import { masterDetailLayoutTransitionStyles } from './styles/vaadin-master-detail-layout-transition-base-styles.js';
15
-
16
- function parseTrackSizes(gridTemplate) {
17
- return gridTemplate
18
- .replace(/\[[^\]]+\]/gu, '')
19
- .replace(/\s+/gu, ' ')
20
- .trim()
21
- .split(' ')
22
- .map(parseFloat);
23
- }
14
+ import {
15
+ animateIn,
16
+ animateOut,
17
+ cancelAnimations,
18
+ detectOverflow,
19
+ getCurrentAnimationProgress,
20
+ parseTrackSizes,
21
+ } from './vaadin-master-detail-layout-helpers.js';
24
22
 
25
23
  /**
26
24
  * `<vaadin-master-detail-layout>` is a web component for building UIs with a master
27
25
  * (or primary) area and a detail (or secondary) area that is displayed next to, or
28
26
  * overlaid on top of, the master area, depending on configuration and viewport size.
29
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
+ *
30
53
  * ### Styling
31
54
  *
32
55
  * The following shadow DOM parts are available for styling:
33
56
  *
34
- * Part name | Description
35
- * ---------------|----------------------
36
- * `backdrop` | Backdrop covering the master area in the overlay mode
37
- * `master` | The master area
38
- * `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
39
63
  *
40
64
  * The following state attributes are available for styling:
41
65
  *
42
- * Attribute | Description
43
- * ----------------------|----------------------
44
- * `expand` | Set to `master`, `detail`, or `both`.
45
- * `orientation` | Set to `horizontal` or `vertical` depending on the orientation.
46
- * `has-detail` | Set when the detail content is provided and visible.
47
- * `overflow` | Set when columns don't fit and the detail is shown as an overlay.
48
- * `overlay-containment` | Set to `layout` or `viewport`.
66
+ * Attribute | Description
67
+ * --------------------------|----------------------
68
+ * `expand-master` | Set when the master area expands to fill available space.
69
+ * `expand-detail` | Set when the detail area expands to fill available space.
70
+ * `orientation` | Set to `horizontal` or `vertical` depending on the orientation.
71
+ * `has-detail` | Set when the detail content is provided and visible.
72
+ * `has-detail-placeholder` | Set when the detail placeholder content is provided.
73
+ * `overlay` | Set when columns don't fit and the detail is shown as an overlay.
74
+ * `overlay-containment` | Set to `layout` or `page`.
49
75
  *
50
76
  * The following custom CSS properties are available for styling:
51
77
  *
@@ -64,11 +90,8 @@ function parseTrackSizes(gridTemplate) {
64
90
  *
65
91
  * @customElement vaadin-master-detail-layout
66
92
  * @extends HTMLElement
67
- * @mixes ThemableMixin
68
- * @mixes ElementMixin
69
- * @mixes SlotStylesMixin
70
93
  */
71
- class MasterDetailLayout extends SlotStylesMixin(ElementMixin(ThemableMixin(PolylitMixin(LitElement)))) {
94
+ class MasterDetailLayout extends ElementMixin(ThemableMixin(PolylitMixin(LitElement))) {
72
95
  static get is() {
73
96
  return 'vaadin-master-detail-layout';
74
97
  }
@@ -81,16 +104,21 @@ class MasterDetailLayout extends SlotStylesMixin(ElementMixin(ThemableMixin(Poly
81
104
  return {
82
105
  /**
83
106
  * Size (in CSS length units) to be set on the detail area in
84
- * the CSS grid layout. If there is not enough space to show
107
+ * the CSS grid layout. When there is not enough space to show
85
108
  * master and detail areas next to each other, the detail area
86
- * is shown as an overlay. Defaults to 15em.
109
+ * is shown as an overlay.
110
+ * <p>
111
+ * If not specified, the size is determined automatically by measuring
112
+ * the detail content in a `min-content` CSS grid column when it first
113
+ * becomes visible, and then caching the resulting intrinsic size. To
114
+ * recalculate the cached intrinsic size, use the `recalculateLayout`
115
+ * method.
87
116
  *
88
117
  * @attr {string} detail-size
89
118
  */
90
119
  detailSize: {
91
120
  type: String,
92
121
  sync: true,
93
- observer: '__detailSizeChanged',
94
122
  },
95
123
 
96
124
  /**
@@ -104,7 +132,6 @@ class MasterDetailLayout extends SlotStylesMixin(ElementMixin(ThemableMixin(Poly
104
132
  masterSize: {
105
133
  type: String,
106
134
  sync: true,
107
- observer: '__masterSizeChanged',
108
135
  },
109
136
 
110
137
  /**
@@ -136,7 +163,7 @@ class MasterDetailLayout extends SlotStylesMixin(ElementMixin(ThemableMixin(Poly
136
163
  /**
137
164
  * Defines the containment of the detail area when the layout is in
138
165
  * overlay mode. When set to `layout`, the overlay is confined to the
139
- * layout. When set to `viewport`, the overlay is confined to the
166
+ * layout. When set to `page`, the overlay is confined to the
140
167
  * browser's viewport. Defaults to `layout`.
141
168
  *
142
169
  * @attr {string} overlay-containment
@@ -149,13 +176,29 @@ class MasterDetailLayout extends SlotStylesMixin(ElementMixin(ThemableMixin(Poly
149
176
  },
150
177
 
151
178
  /**
152
- * Controls which column(s) expand to fill available space.
153
- * Possible values: `'master'`, `'detail'`, `'both'`.
154
- * Defaults to `'both'`.
179
+ * When true, the master area grows to fill the available space.
180
+ * If `expandDetail` is also true, both areas share the available
181
+ * space equally.
182
+ *
183
+ * @attr {boolean} expand-master
155
184
  */
156
- expand: {
157
- type: String,
158
- value: 'both',
185
+ expandMaster: {
186
+ type: Boolean,
187
+ value: false,
188
+ reflectToAttribute: true,
189
+ sync: true,
190
+ },
191
+
192
+ /**
193
+ * When true, the detail area grows to fill the available space.
194
+ * If `expandMaster` is also true, both areas share the available
195
+ * space equally.
196
+ *
197
+ * @attr {boolean} expand-detail
198
+ */
199
+ expandDetail: {
200
+ type: Boolean,
201
+ value: false,
159
202
  reflectToAttribute: true,
160
203
  sync: true,
161
204
  },
@@ -168,6 +211,28 @@ class MasterDetailLayout extends SlotStylesMixin(ElementMixin(ThemableMixin(Poly
168
211
  noAnimation: {
169
212
  type: Boolean,
170
213
  value: false,
214
+ reflectToAttribute: true,
215
+ },
216
+
217
+ /**
218
+ * When true, the layout forces the detail area to be shown as an overlay,
219
+ * even if there is enough space for master and detail to be shown next to
220
+ * each other using the default (split) mode.
221
+ *
222
+ * @attr {boolean} force-overlay
223
+ */
224
+ forceOverlay: {
225
+ type: Boolean,
226
+ value: false,
227
+ reflectToAttribute: true,
228
+ sync: true,
229
+ },
230
+
231
+ /** @private */
232
+ __detailCachedSize: {
233
+ type: String,
234
+ observer: '__detailCachedSizeChanged',
235
+ sync: true,
171
236
  },
172
237
  };
173
238
  }
@@ -176,31 +241,32 @@ class MasterDetailLayout extends SlotStylesMixin(ElementMixin(ThemableMixin(Poly
176
241
  return true;
177
242
  }
178
243
 
179
- /** @return {!Array<!CSSResult>} */
180
- get slotStyles() {
181
- return [masterDetailLayoutTransitionStyles];
182
- }
183
-
184
244
  /** @protected */
185
245
  render() {
186
- const isOverlay = this.hasAttribute('has-detail') && this.hasAttribute('overflow');
187
- const isViewport = isOverlay && this.overlayContainment === 'viewport';
188
- const isLayoutContained = isOverlay && !isViewport;
246
+ const isOverlay = this.hasAttribute('has-detail') && this.hasAttribute('overlay');
247
+ const isPage = isOverlay && this.overlayContainment === 'page';
248
+ const isLayoutContained = isOverlay && !isPage;
189
249
 
190
250
  return html`
191
- <div part="backdrop" @click="${this.__onBackdropClick}"></div>
251
+ <div id="backdrop" part="backdrop" @click="${this.__onBackdropClick}"></div>
192
252
  <div id="master" part="master" ?inert="${isLayoutContained}">
193
253
  <slot @slotchange="${this.__onSlotChange}"></slot>
194
254
  </div>
255
+ <div id="detailOutgoing" inert>
256
+ <slot name="detail-outgoing"></slot>
257
+ </div>
195
258
  <div
196
259
  id="detail"
197
260
  part="detail"
198
261
  role="${isOverlay ? 'dialog' : nothing}"
199
- aria-modal="${isViewport ? 'true' : nothing}"
262
+ aria-modal="${isPage ? 'true' : nothing}"
200
263
  @keydown="${this.__onDetailKeydown}"
201
264
  >
202
265
  <slot name="detail" @slotchange="${this.__onSlotChange}"></slot>
203
266
  </div>
267
+ <div id="detailPlaceholder" part="detail-placeholder">
268
+ <slot name="detail-placeholder" @slotchange="${this.__onSlotChange}"></slot>
269
+ </div>
204
270
  `;
205
271
  }
206
272
 
@@ -208,6 +274,17 @@ class MasterDetailLayout extends SlotStylesMixin(ElementMixin(ThemableMixin(Poly
208
274
  connectedCallback() {
209
275
  super.connectedCallback();
210
276
  this.__initResizeObserver();
277
+
278
+ const ancestorLayouts = this.__ancestorLayouts;
279
+ if (ancestorLayouts.length > 0) {
280
+ ancestorLayouts.forEach((layout) => {
281
+ cancelAnimationFrame(layout.__initialRaf);
282
+ });
283
+
284
+ this.__initialRaf = requestAnimationFrame(() => {
285
+ this.recalculateLayout();
286
+ });
287
+ }
211
288
  }
212
289
 
213
290
  /** @protected */
@@ -215,30 +292,40 @@ class MasterDetailLayout extends SlotStylesMixin(ElementMixin(ThemableMixin(Poly
215
292
  super.disconnectedCallback();
216
293
  this.__resizeObserver.disconnect();
217
294
  cancelAnimationFrame(this.__resizeRaf);
295
+ cancelAnimationFrame(this.__initialRaf);
296
+ cancelAnimations(this);
218
297
  }
219
298
 
220
- /** @private */
221
- __masterSizeChanged(size, oldSize) {
222
- this.__updateStyleProperty('master-size', size, oldSize);
223
- }
299
+ /** @protected */
300
+ updated(props) {
301
+ super.updated(props);
224
302
 
225
- /** @private */
226
- __detailSizeChanged(size, oldSize) {
227
- this.__updateStyleProperty('detail-size', size, oldSize);
303
+ if (props.has('masterSize')) {
304
+ this.style.setProperty('--_master-size', this.masterSize);
305
+ }
306
+
307
+ if (props.has('detailSize')) {
308
+ this.style.setProperty('--_detail-size', this.detailSize);
309
+ }
310
+
311
+ if (
312
+ (props.has('masterSize') && props.get('masterSize') != null) ||
313
+ (props.has('detailSize') && props.get('detailSize') != null) ||
314
+ (props.has('orientation') && props.get('orientation') != null) ||
315
+ (props.has('forceOverlay') && props.get('forceOverlay') != null)
316
+ ) {
317
+ this.recalculateLayout();
318
+ }
228
319
  }
229
320
 
230
321
  /** @private */
231
- __overlaySizeChanged(size, oldSize) {
232
- this.__updateStyleProperty('overlay-size', size, oldSize);
322
+ __overlaySizeChanged(size) {
323
+ this.style.setProperty('--_overlay-size', size);
233
324
  }
234
325
 
235
326
  /** @private */
236
- __updateStyleProperty(prop, size, oldSize) {
237
- if (size) {
238
- this.style.setProperty(`--_${prop}`, size);
239
- } else if (oldSize) {
240
- this.style.removeProperty(`--_${prop}`);
241
- }
327
+ __detailCachedSizeChanged(size) {
328
+ this.style.setProperty('--_detail-cached-size', size);
242
329
  }
243
330
 
244
331
  /** @private */
@@ -248,12 +335,13 @@ class MasterDetailLayout extends SlotStylesMixin(ElementMixin(ThemableMixin(Poly
248
335
 
249
336
  /** @private */
250
337
  __initResizeObserver() {
251
- this.__resizeObserver = this.__resizeObserver || new ResizeObserver(() => this.__onResize());
338
+ this.__resizeObserver ||= new ResizeObserver(() => this.__onResize());
252
339
  this.__resizeObserver.disconnect();
253
340
 
254
- const children = this.querySelectorAll(':scope > [slot="detail"], :scope >:not([slot])');
255
- [this, this.$.master, this.$.detail, ...children].forEach((node) => {
256
- this.__resizeObserver.observe(node);
341
+ [this, this.$.master, this.$.detail, this.__slottedMaster, this.__slottedDetail].forEach((node) => {
342
+ if (node) {
343
+ this.__resizeObserver.observe(node);
344
+ }
257
345
  });
258
346
  }
259
347
 
@@ -264,9 +352,9 @@ class MasterDetailLayout extends SlotStylesMixin(ElementMixin(ThemableMixin(Poly
264
352
  * @private
265
353
  */
266
354
  __onResize() {
267
- const state = this.__computeLayoutState();
355
+ const state = this.__readLayoutState();
268
356
  cancelAnimationFrame(this.__resizeRaf);
269
- this.__resizeRaf = requestAnimationFrame(() => this.__applyLayoutState(state));
357
+ this.__resizeRaf = requestAnimationFrame(() => this.__writeLayoutState(state));
270
358
  }
271
359
 
272
360
  /**
@@ -274,57 +362,133 @@ class MasterDetailLayout extends SlotStylesMixin(ElementMixin(ThemableMixin(Poly
274
362
  * ResizeObserver callback where layout is already computed (no forced reflow).
275
363
  * @private
276
364
  */
277
- __computeLayoutState() {
278
- const detailContent = this.querySelector('[slot="detail"]');
365
+ __readLayoutState() {
366
+ const isVertical = this.orientation === 'vertical';
367
+
368
+ const slottedMaster = this.__slottedMaster;
369
+ const slottedDetail = this.__slottedDetail;
370
+ const slottedDetailPlaceholder = this.__slottedDetailPlaceholder;
371
+
372
+ const hasMaster = !!slottedMaster;
279
373
  const hadDetail = this.hasAttribute('has-detail');
280
- const hasDetail = detailContent != null && detailContent.checkVisibility();
281
- const hasOverflow = hasDetail && this.__checkOverflow();
282
- const focusTarget = !hadDetail && hasDetail && hasOverflow ? getFocusableElements(detailContent)[0] : null;
283
- return { hadDetail, hasDetail, hasOverflow, focusTarget };
374
+ const hasDetail = slottedDetail != null && slottedDetail.checkVisibility();
375
+ const hasDetailPlaceholder = !!slottedDetailPlaceholder;
376
+
377
+ const computedStyle = getComputedStyle(this);
378
+ const hostSizeProp = isVertical ? 'height' : 'width';
379
+ const hostSize = parseFloat(computedStyle[hostSizeProp]);
380
+
381
+ const trackSizesProp = isVertical ? 'gridTemplateRows' : 'gridTemplateColumns';
382
+ const trackSizes = parseTrackSizes(computedStyle[trackSizesProp]);
383
+
384
+ const hasOverflow =
385
+ (hasDetail || hasDetailPlaceholder) && (this.forceOverlay || detectOverflow(hostSize, trackSizes));
386
+ const focusTarget = !hadDetail && hasDetail && hasOverflow ? getFocusableElements(slottedDetail)[0] : null;
387
+
388
+ return {
389
+ hasMaster,
390
+ hadDetail,
391
+ hasDetail,
392
+ hasDetailPlaceholder,
393
+ hasOverflow,
394
+ focusTarget,
395
+ hostSize,
396
+ trackSizes,
397
+ };
284
398
  }
285
399
 
286
400
  /**
287
401
  * Applies layout state to DOM attributes. Pure writes, no reads.
288
402
  * @private
289
403
  */
290
- __applyLayoutState({ hadDetail, hasDetail, hasOverflow, focusTarget }) {
291
- // Set keep-detail-column-offscreen when detail first appears with overflow
292
- // to prevent master width from jumping.
404
+ __writeLayoutState({ hasMaster, hadDetail, hasDetail, hasDetailPlaceholder, hasOverflow, focusTarget, trackSizes }) {
405
+ const [_masterSize, _masterExtra, detailSize] = trackSizes;
406
+
407
+ // If no detailSize is explicitily set, cache the intrinsic size (min-content) of
408
+ // the slotted detail content to use as a fallback for the detail column size
409
+ // while the detail content is rendered in an overlay.
410
+ if ((hasDetail || hasDetailPlaceholder) && this.__isDetailAutoSized && detailSize > 0) {
411
+ this.__detailCachedSize ||= `${Math.ceil(detailSize)}px`;
412
+ } else {
413
+ this.__detailCachedSize = null;
414
+ }
415
+
416
+ // Force the detail column offscreen when it first appears and overflow
417
+ // is already detected. This prevents unnecessary master column shrinking,
418
+ // as the detail content is rendered in an overlay anyway.
293
419
  if (!hadDetail && hasDetail && hasOverflow) {
294
420
  this.setAttribute('keep-detail-column-offscreen', '');
295
421
  } else if (!hasDetail || !hasOverflow) {
296
422
  this.removeAttribute('keep-detail-column-offscreen');
297
423
  }
298
424
 
425
+ this.toggleAttribute('overlay', hasOverflow);
426
+ this.toggleAttribute('has-master', hasMaster);
299
427
  this.toggleAttribute('has-detail', hasDetail);
300
- this.toggleAttribute('overflow', hasOverflow);
428
+ this.toggleAttribute('has-detail-placeholder', hasDetailPlaceholder);
301
429
 
302
430
  // Re-render to update ARIA attributes (role, aria-modal, inert)
303
- // which depend on has-detail and overflow state.
431
+ // which depend on has-detail and overlay state.
304
432
  this.requestUpdate();
305
433
 
306
434
  if (focusTarget) {
307
- focusTarget.focus();
435
+ focusTarget.focus({ preventScroll: true, focusVisible: isKeyboardActive() });
308
436
  }
309
437
  }
310
438
 
311
- /** @private */
312
- __checkOverflow() {
313
- const isVertical = this.orientation === 'vertical';
314
- const computedStyle = getComputedStyle(this);
439
+ /**
440
+ * When `detailSize` is not explicitly set, re-measures the cached intrinsic size of
441
+ * the detail content by placing it in a min-content CSS grid column, then repeats
442
+ * this process for ancestor master-detail layouts without an explicit `detailSize`,
443
+ * if any, so that their detail areas also adapt.
444
+ *
445
+ * Call this method after changing the detail content in a way that affects its intrinsic
446
+ * size — for example, when opening a detail in a nested master-detail layout that was
447
+ * not previously visible.
448
+ *
449
+ * NOTE: This method can be expensive in large layouts as it triggers consecutive
450
+ * synchronous DOM reads and writes.
451
+ */
452
+ recalculateLayout() {
453
+ const invalidatedLayouts = [...this.__ancestorLayouts, this];
315
454
 
316
- const hostSize = parseFloat(computedStyle[isVertical ? 'height' : 'width']);
317
- const [masterSize, masterExtra, detailSize] = parseTrackSizes(
318
- computedStyle[isVertical ? 'gridTemplateRows' : 'gridTemplateColumns'],
319
- );
455
+ // Write
456
+ invalidatedLayouts.forEach((layout) => {
457
+ // Cancel any pending ResizeObserver rAF to prevent it from potentially
458
+ // overriding the layout state with stale measurements.
459
+ cancelAnimationFrame(layout.__resizeRaf);
320
460
 
321
- if (Math.floor(masterSize + masterExtra + detailSize) <= Math.floor(hostSize)) {
322
- return false;
323
- }
324
- if (Math.floor(masterExtra) >= Math.floor(detailSize)) {
325
- return false;
326
- }
327
- return true;
461
+ layout.__detailCachedSize = null;
462
+
463
+ if (layout.__isDetailAutoSized) {
464
+ layout.removeAttribute('overlay');
465
+ layout.toggleAttribute('recalculating-detail-size', true);
466
+ }
467
+ });
468
+
469
+ // Read/Write
470
+ invalidatedLayouts.forEach((layout) => {
471
+ const state = layout.__readLayoutState();
472
+ layout.__writeLayoutState(state);
473
+ });
474
+
475
+ // Write
476
+ invalidatedLayouts.forEach((layout) => {
477
+ if (layout.__isDetailAutoSized) {
478
+ layout.toggleAttribute('recalculating-detail-size', false);
479
+ }
480
+ });
481
+ }
482
+
483
+ /** @private */
484
+ get __isDetailAutoSized() {
485
+ return this.detailSize == null;
486
+ }
487
+
488
+ /** @private */
489
+ get __ancestorLayouts() {
490
+ const parent = getClosestElement(this.constructor.is, this.parentNode);
491
+ return parent ? [...parent.__ancestorLayouts, parent] : [];
328
492
  }
329
493
 
330
494
  /** @private */
@@ -342,128 +506,181 @@ class MasterDetailLayout extends SlotStylesMixin(ElementMixin(ThemableMixin(Poly
342
506
  }
343
507
 
344
508
  /**
345
- * Sets the detail element to be displayed in the detail area and starts a
346
- * view transition that animates adding, replacing or removing the detail
347
- * area. During the view transition, the element is added to the DOM and
348
- * assigned to the `detail` slot. Any previous detail element is removed.
349
- * When passing null as the element, the current detail element is removed.
509
+ * Sets the detail element to be displayed in the detail area and starts an
510
+ * animated transition for adding, replacing or removing the detail area.
511
+ * The element is added to the DOM and assigned to the `detail` slot. Any
512
+ * previous detail element is removed. When passing null as the element,
513
+ * the current detail element is removed.
350
514
  *
351
- * If the browser does not support view transitions, the respective updates
352
- * are applied immediately without starting a transition. The transition can
353
- * also be skipped using the `skipTransition` parameter.
515
+ * The transition can be skipped using the `skipTransition` parameter or
516
+ * the `noAnimation` property.
354
517
  *
355
518
  * @param element the new detail element, or null to remove the current detail
356
519
  * @param skipTransition whether to skip the transition
357
- * @returns {Promise<void>}
520
+ * @return {Promise<void>}
358
521
  * @protected
359
522
  */
360
- _setDetail(element, skipTransition) {
523
+ async _setDetail(newDetail, skipTransition) {
361
524
  // Don't start a transition if detail didn't change
362
- const currentDetail = this.querySelector('[slot="detail"]');
363
- if ((element || null) === currentDetail) {
364
- return Promise.resolve();
525
+ const oldDetail = this.__slottedDetail;
526
+ if (oldDetail === (newDetail || null)) {
527
+ return;
365
528
  }
366
529
 
367
- const updateSlot = () => {
368
- // Remove old content
369
- this.querySelectorAll('[slot="detail"]').forEach((oldElement) => oldElement.remove());
370
- // Add new content
371
- if (element) {
372
- element.setAttribute('slot', 'detail');
373
- this.appendChild(element);
530
+ const updateSlot = async () => {
531
+ if (oldDetail?.slot === 'detail') {
532
+ oldDetail.remove();
533
+ }
534
+
535
+ if (newDetail) {
536
+ newDetail.setAttribute('slot', 'detail');
537
+ this.appendChild(newDetail);
374
538
  }
539
+
540
+ // Wait for Lit elements to render
541
+ await Promise.resolve();
542
+
543
+ this.recalculateLayout();
375
544
  };
376
545
 
377
- if (skipTransition) {
378
- updateSlot();
379
- return Promise.resolve();
546
+ if (skipTransition || this.noAnimation) {
547
+ await updateSlot();
548
+ return;
380
549
  }
381
550
 
382
- const hasDetail = !!currentDetail;
383
- const transitionType = hasDetail && element ? 'replace' : hasDetail ? 'remove' : 'add';
384
- return this._startTransition(transitionType, () => {
385
- // Update the DOM
386
- updateSlot();
387
- // Finish the transition
388
- this._finishTransition();
389
- });
551
+ const hasPlaceholder = !!this.__slottedDetailPlaceholder;
552
+ if ((oldDetail && newDetail) || (hasPlaceholder && !this.hasAttribute('overlay'))) {
553
+ await this._startTransition('replace', updateSlot);
554
+ } else if (!oldDetail && newDetail) {
555
+ await this._startTransition('add', updateSlot);
556
+ } else if (oldDetail && !newDetail) {
557
+ await this._startTransition('remove', updateSlot);
558
+ }
390
559
  }
391
560
 
392
561
  /**
393
- * Starts a view transition that animates adding, replacing or removing the
394
- * detail area. Once the transition is ready and the browser has taken a
395
- * snapshot of the current layout, the provided update callback is called.
396
- * The callback should update the DOM, which can happen asynchronously.
397
- * Once the DOM is updated, the caller must call `_finishTransition`,
398
- * which results in the browser taking a snapshot of the new layout and
399
- * animating the transition.
562
+ * Starts an animated transition for adding, replacing or removing the
563
+ * detail area using the Web Animations API.
564
+ *
565
+ * For 'add'/'replace': DOM is updated immediately, then animation
566
+ * starts after a microtask (so Lit elements render and layout is
567
+ * recalculated before animation params are read).
400
568
  *
401
- * If the browser does not support view transitions, or the `noAnimation`
402
- * property is set, the update callback is called immediately without
403
- * starting a transition.
569
+ * For 'remove': animation plays first, then DOM is updated after
570
+ * the slide-out completes.
571
+ *
572
+ * Interruptible: a new transition cancels any in-progress animation
573
+ * and picks up from the interrupted position.
404
574
  *
405
575
  * @param transitionType
406
- * @param updateCallback
407
- * @returns {Promise<void>}
576
+ * @param updateSlot
577
+ * @return {Promise<void>}
408
578
  * @protected
409
579
  */
410
- _startTransition(transitionType, updateCallback) {
411
- const useTransition = typeof document.startViewTransition === 'function' && !this.noAnimation;
412
- if (!useTransition) {
413
- updateCallback();
414
- return Promise.resolve();
580
+ async _startTransition(transitionType, updateSlot) {
581
+ if (this.noAnimation) {
582
+ await updateSlot();
583
+ return;
415
584
  }
416
585
 
417
- this.setAttribute('transition', transitionType);
418
- this.__transition = document.startViewTransition(() => {
419
- // Return a promise that can be resolved once the DOM is updated
420
- return new Promise((resolve) => {
421
- this.__resolveUpdateCallback = resolve;
422
- // Notify the caller that the transition is ready, so that they can
423
- // update the DOM
424
- updateCallback();
425
- });
426
- });
427
- return this.__transition.finished;
586
+ try {
587
+ this.setAttribute('transition', transitionType);
588
+
589
+ switch (transitionType) {
590
+ case 'add':
591
+ await this.__addTransition(updateSlot);
592
+ break;
593
+ case 'remove':
594
+ await this.__removeTransition(updateSlot);
595
+ break;
596
+ default:
597
+ await this.__replaceTransition(updateSlot);
598
+ break;
599
+ }
600
+
601
+ this.removeAttribute('transition');
602
+ } catch (e) {
603
+ if (e instanceof DOMException && e.name === 'AbortError') {
604
+ return; // Animation was cancelled
605
+ }
606
+ throw e;
607
+ }
428
608
  }
429
609
 
430
- /**
431
- * Finishes the current view transition, if any. This method should be called
432
- * after the DOM has been updated to finish the transition and animate the
433
- * change in the layout.
434
- *
435
- * @returns {Promise<void>}
436
- * @protected
437
- */
438
- async _finishTransition() {
439
- // Detect layout mode before resolving the transition, so the browser's
440
- // "new" snapshot includes the correct overlay state. The microtask runs
441
- // before the Promise resolution propagates to startViewTransition.
442
- queueMicrotask(() => {
443
- const state = this.__computeLayoutState();
444
- this.__applyLayoutState(state);
445
- });
610
+ /** @private */
611
+ async __addTransition(updateSlot) {
612
+ await updateSlot();
613
+
614
+ const progress = getCurrentAnimationProgress(this.$.detail);
615
+
616
+ if (this.hasAttribute('overlay')) {
617
+ await Promise.all([
618
+ animateIn(this.$.detail, ['slide'], progress),
619
+ animateIn(this.$.backdrop, ['fade'], progress),
620
+ ]);
621
+ } else {
622
+ await animateIn(this.$.detail, ['slide', 'fade'], progress);
623
+ }
624
+ }
625
+
626
+ /** @private */
627
+ async __replaceTransition(updateSlot) {
628
+ const oldDetail = this.__slottedDetail;
629
+ if (oldDetail) {
630
+ oldDetail.slot = 'detail-outgoing';
631
+ }
632
+
633
+ try {
634
+ this.$.detailOutgoing.style.width = this.__detailCachedSize;
635
+
636
+ await updateSlot();
637
+
638
+ const isOverlay = this.hasAttribute('overlay');
639
+ const progress = getCurrentAnimationProgress(this.$.detail);
446
640
 
447
- if (!this.__transition) {
448
- return Promise.resolve();
641
+ await Promise.all([
642
+ animateIn(this.$.detail, isOverlay ? ['slide'] : ['slide', 'fade'], progress),
643
+ animateOut(this.$.detailOutgoing, isOverlay ? ['slide'] : ['slide', 'fade'], progress),
644
+ ]);
645
+ } finally {
646
+ // Skip removal if the slot was reassigned during the transition.
647
+ // The React component does this to let React handle the removal.
648
+ if (oldDetail?.slot === 'detail-outgoing') {
649
+ oldDetail.remove();
650
+ }
449
651
  }
450
- // Resolve the update callback to finish the transition
451
- this.__resolveUpdateCallback();
452
- await this.__transition.finished;
453
- this.removeAttribute('transition');
454
- this.__transition = null;
455
- this.__resolveUpdateCallback = null;
456
652
  }
457
653
 
458
- /**
459
- * @event backdrop-click
460
- * Fired when the user clicks the backdrop in the overlay mode.
461
- */
654
+ /** @private */
655
+ async __removeTransition(updateSlot) {
656
+ const progress = getCurrentAnimationProgress(this.$.detail);
657
+
658
+ if (this.hasAttribute('overlay')) {
659
+ await Promise.all([
660
+ animateOut(this.$.detail, ['slide'], progress),
661
+ animateOut(this.$.backdrop, ['fade'], progress),
662
+ ]);
663
+ } else {
664
+ await animateOut(this.$.detail, ['slide', 'fade'], progress);
665
+ }
462
666
 
463
- /**
464
- * @event detail-escape-press
465
- * Fired when the user presses Escape in the detail area.
466
- */
667
+ await updateSlot();
668
+ }
669
+
670
+ /** @private */
671
+ get __slottedMaster() {
672
+ return this.querySelector(':scope > :is([slot=""], :not([slot]))');
673
+ }
674
+
675
+ /** @private */
676
+ get __slottedDetail() {
677
+ return this.querySelector(':scope > [slot="detail"]');
678
+ }
679
+
680
+ /** @private */
681
+ get __slottedDetailPlaceholder() {
682
+ return this.querySelector(':scope > [slot="detail-placeholder"]');
683
+ }
467
684
  }
468
685
 
469
686
  defineCustomElement(MasterDetailLayout);