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

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