@vaadin/master-detail-layout 25.1.2 → 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,40 +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 { ResizeMixin } from '@vaadin/component-base/src/resize-mixin.js';
12
- import { SlotStylesMixin } from '@vaadin/component-base/src/slot-styles-mixin.js';
13
12
  import { ThemableMixin } from '@vaadin/vaadin-themable-mixin/vaadin-themable-mixin.js';
14
13
  import { masterDetailLayoutStyles } from './styles/vaadin-master-detail-layout-base-styles.js';
15
- import { masterDetailLayoutTransitionStyles } from './styles/vaadin-master-detail-layout-transition-base-styles.js';
14
+ import {
15
+ animateIn,
16
+ animateOut,
17
+ cancelAnimations,
18
+ detectOverflow,
19
+ getCurrentAnimationProgress,
20
+ parseTrackSizes,
21
+ } from './vaadin-master-detail-layout-helpers.js';
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-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`.
41
75
  *
42
76
  * The following custom CSS properties are available for styling:
43
77
  *
@@ -51,17 +85,15 @@ import { masterDetailLayoutTransitionStyles } from './styles/vaadin-master-detai
51
85
  *
52
86
  * See [Styling Components](https://vaadin.com/docs/latest/styling/styling-components) documentation.
53
87
  *
54
- * @fires {CustomEvent} backdrop-click - Fired when the user clicks the backdrop in the drawer mode.
88
+ * @fires {CustomEvent} backdrop-click - Fired when the user clicks the backdrop in the overlay mode.
55
89
  * @fires {CustomEvent} detail-escape-press - Fired when the user presses Escape in the detail area.
56
90
  *
57
91
  * @customElement vaadin-master-detail-layout
58
92
  * @extends HTMLElement
59
93
  * @mixes ThemableMixin
60
94
  * @mixes ElementMixin
61
- * @mixes ResizeMixin
62
- * @mixes SlotStylesMixin
63
95
  */
64
- class MasterDetailLayout extends SlotStylesMixin(ResizeMixin(ElementMixin(ThemableMixin(PolylitMixin(LitElement))))) {
96
+ class MasterDetailLayout extends ElementMixin(ThemableMixin(PolylitMixin(LitElement))) {
65
97
  static get is() {
66
98
  return 'vaadin-master-detail-layout';
67
99
  }
@@ -73,63 +105,48 @@ class MasterDetailLayout extends SlotStylesMixin(ResizeMixin(ElementMixin(Themab
73
105
  static get properties() {
74
106
  return {
75
107
  /**
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.
108
+ * Size (in CSS length units) to be set on the detail area in
109
+ * the CSS grid layout. When there is not enough space to show
110
+ * master and detail areas next to each other, the detail area
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.
81
118
  *
82
119
  * @attr {string} detail-size
83
120
  */
84
121
  detailSize: {
85
122
  type: String,
86
123
  sync: true,
87
- observer: '__detailSizeChanged',
88
- },
89
-
90
- /**
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
124
  },
104
125
 
105
126
  /**
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.
127
+ * Size (in CSS length units) to be set on the master area in
128
+ * the CSS grid layout. If there is not enough space to show
129
+ * master and detail areas next to each other, the detail area
130
+ * is shown as an overlay. Defaults to 30em.
111
131
  *
112
132
  * @attr {string} master-size
113
133
  */
114
134
  masterSize: {
115
135
  type: String,
116
136
  sync: true,
117
- observer: '__masterSizeChanged',
118
137
  },
119
138
 
120
139
  /**
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.
140
+ * Size (in CSS length units) for the detail area when shown as an
141
+ * overlay. When not set, falls back to `detailSize`. Set to `100%`
142
+ * to make the detail cover the full layout.
126
143
  *
127
- * @attr {string} master-min-size
144
+ * @attr {string} overlay-size
128
145
  */
129
- masterMinSize: {
146
+ overlaySize: {
130
147
  type: String,
131
148
  sync: true,
132
- observer: '__masterMinSizeChanged',
149
+ observer: '__overlaySizeChanged',
133
150
  },
134
151
 
135
152
  /**
@@ -142,35 +159,18 @@ class MasterDetailLayout extends SlotStylesMixin(ResizeMixin(ElementMixin(Themab
142
159
  type: String,
143
160
  value: 'horizontal',
144
161
  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
162
  sync: true,
165
163
  },
166
164
 
167
165
  /**
168
166
  * Defines the containment of the detail area when the layout is in
169
167
  * overlay mode. When set to `layout`, the overlay is confined to the
170
- * layout. When set to `viewport`, the overlay is confined to the
168
+ * layout. When set to `page`, the overlay is confined to the
171
169
  * browser's viewport. Defaults to `layout`.
170
+ *
171
+ * @attr {string} overlay-containment
172
172
  */
173
- containment: {
173
+ overlayContainment: {
174
174
  type: String,
175
175
  value: 'layout',
176
176
  reflectToAttribute: true,
@@ -178,62 +178,62 @@ class MasterDetailLayout extends SlotStylesMixin(ResizeMixin(ElementMixin(Themab
178
178
  },
179
179
 
180
180
  /**
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`.
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.
187
184
  *
188
- * @attr {string} stack-threshold
185
+ * @attr {boolean} expand-master
189
186
  */
190
- stackOverlay: {
187
+ expandMaster: {
191
188
  type: Boolean,
192
189
  value: false,
193
- observer: '__stackOverlayChanged',
190
+ reflectToAttribute: true,
194
191
  sync: true,
195
192
  },
196
193
 
197
194
  /**
198
- * When true, the layout does not use animated transitions for the detail area.
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.
199
198
  *
200
- * @attr {boolean} no-animation
199
+ * @attr {boolean} expand-detail
201
200
  */
202
- noAnimation: {
201
+ expandDetail: {
203
202
  type: Boolean,
204
203
  value: false,
204
+ reflectToAttribute: true,
205
+ sync: true,
205
206
  },
206
207
 
207
208
  /**
208
- * When true, the component uses the drawer mode. This property is read-only.
209
- * @protected
209
+ * When true, the layout does not use animated transitions for the detail area.
210
+ *
211
+ * @attr {boolean} no-animation
210
212
  */
211
- _drawer: {
213
+ noAnimation: {
212
214
  type: Boolean,
213
- attribute: 'drawer',
215
+ value: false,
214
216
  reflectToAttribute: true,
215
- sync: true,
216
217
  },
217
218
 
218
219
  /**
219
- * When true, the component uses the stack mode. This property is read-only.
220
- * @protected
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
221
225
  */
222
- _stack: {
226
+ forceOverlay: {
223
227
  type: Boolean,
224
- attribute: 'stack',
228
+ value: false,
225
229
  reflectToAttribute: true,
226
230
  sync: true,
227
231
  },
228
232
 
229
- /**
230
- * When true, the component has the detail content provided.
231
- * @protected
232
- */
233
- _hasDetail: {
234
- type: Boolean,
235
- attribute: 'has-detail',
236
- reflectToAttribute: true,
233
+ /** @private */
234
+ __detailCachedSize: {
235
+ type: String,
236
+ observer: '__detailCachedSizeChanged',
237
237
  sync: true,
238
238
  },
239
239
  };
@@ -243,317 +243,434 @@ class MasterDetailLayout extends SlotStylesMixin(ResizeMixin(ElementMixin(Themab
243
243
  return true;
244
244
  }
245
245
 
246
- /** @override */
247
- get slotStyles() {
248
- return [masterDetailLayoutTransitionStyles];
249
- }
250
-
251
246
  /** @protected */
252
247
  render() {
248
+ const isOverlay = this.hasAttribute('has-detail') && this.hasAttribute('overlay');
249
+ const isPage = isOverlay && this.overlayContainment === 'page';
250
+ const isLayoutContained = isOverlay && !isPage;
251
+
253
252
  return html`
254
- <div part="backdrop" @click="${this.__onBackdropClick}"></div>
253
+ <div id="backdrop" part="backdrop" @click="${this.__onBackdropClick}"></div>
254
+ <div id="master" part="master" ?inert="${isLayoutContained}">
255
+ <slot @slotchange="${this.__onSlotChange}"></slot>
256
+ </div>
257
+ <div id="detailOutgoing" inert>
258
+ <slot name="detail-outgoing"></slot>
259
+ </div>
255
260
  <div
256
- id="master"
257
- part="master"
258
- ?inert="${this._hasDetail && (this._stack || (this._drawer && this.containment === 'layout'))}"
261
+ id="detail"
262
+ part="detail"
263
+ role="${isOverlay ? 'dialog' : nothing}"
264
+ aria-modal="${isPage ? 'true' : nothing}"
265
+ @keydown="${this.__onDetailKeydown}"
259
266
  >
260
- <slot></slot>
267
+ <slot name="detail" @slotchange="${this.__onSlotChange}"></slot>
261
268
  </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>
269
+ <div id="detailPlaceholder" part="detail-placeholder">
270
+ <slot name="detail-placeholder" @slotchange="${this.__onSlotChange}"></slot>
272
271
  </div>
273
272
  `;
274
273
  }
275
274
 
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
- }
275
+ /** @protected */
276
+ connectedCallback() {
277
+ super.connectedCallback();
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
+ });
290
289
  }
291
290
  }
292
291
 
293
- /** @private */
294
- __onBackdropClick() {
295
- this.dispatchEvent(new CustomEvent('backdrop-click'));
292
+ /** @protected */
293
+ disconnectedCallback() {
294
+ super.disconnectedCallback();
295
+ this.__resizeObserver.disconnect();
296
+ cancelAnimationFrame(this.__resizeRaf);
297
+ cancelAnimationFrame(this.__initialRaf);
298
+ cancelAnimations(this);
296
299
  }
297
300
 
298
- /** @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'));
301
+ /** @protected */
302
+ updated(props) {
303
+ super.updated(props);
304
+
305
+ if (props.has('masterSize')) {
306
+ this.style.setProperty('--_master-size', this.masterSize);
304
307
  }
305
- }
306
308
 
307
- /**
308
- * @protected
309
- * @override
310
- */
311
- _onResize() {
312
- this.__detectLayoutMode();
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
+ }
313
321
  }
314
322
 
315
323
  /** @private */
316
- __detailSizeChanged(size, oldSize) {
317
- this.__updateStyleProperty('detail-size', size, oldSize);
318
- this.__detectLayoutMode();
324
+ __overlaySizeChanged(size) {
325
+ this.style.setProperty('--_overlay-size', size);
319
326
  }
320
327
 
321
328
  /** @private */
322
- __detailMinSizeChanged(size, oldSize) {
323
- this.__updateStyleProperty('detail-min-size', size, oldSize);
324
- this.__detectLayoutMode();
329
+ __detailCachedSizeChanged(size) {
330
+ this.style.setProperty('--_detail-cached-size', size);
325
331
  }
326
332
 
327
333
  /** @private */
328
- __masterSizeChanged(size, oldSize) {
329
- this.__updateStyleProperty('master-size', size, oldSize);
330
- this.__detectLayoutMode();
334
+ __onSlotChange() {
335
+ this.__initResizeObserver();
331
336
  }
332
337
 
333
338
  /** @private */
334
- __masterMinSizeChanged(size, oldSize) {
335
- this.__updateStyleProperty('master-min-size', size, oldSize);
336
- this.__detectLayoutMode();
339
+ __initResizeObserver() {
340
+ this.__resizeObserver ||= new ResizeObserver(() => this.__onResize());
341
+ this.__resizeObserver.disconnect();
342
+
343
+ [this, this.$.master, this.$.detail, this.__slottedMaster, this.__slottedDetail].forEach((node) => {
344
+ if (node) {
345
+ this.__resizeObserver.observe(node);
346
+ }
347
+ });
337
348
  }
338
349
 
339
- /** @private */
340
- __orientationChanged(orientation, oldOrientation) {
341
- if (orientation || oldOrientation) {
342
- this.__detectLayoutMode();
343
- }
350
+ /**
351
+ * Called by the ResizeObserver. Reads layout state synchronously (no forced
352
+ * reflow since layout is already computed), then defers writes to rAF.
353
+ * Cancels any pending rAF so the write phase always uses the latest state.
354
+ * @private
355
+ */
356
+ __onResize() {
357
+ const state = this.__readLayoutState();
358
+ cancelAnimationFrame(this.__resizeRaf);
359
+ this.__resizeRaf = requestAnimationFrame(() => this.__writeLayoutState(state));
344
360
  }
345
361
 
346
- /** @private */
347
- __forceOverlayChanged(forceOverlay, oldForceOverlay) {
348
- if (forceOverlay || oldForceOverlay) {
349
- this.__detectLayoutMode();
350
- }
362
+ /**
363
+ * Reads DOM/style state needed for layout detection. Safe to call in
364
+ * ResizeObserver callback where layout is already computed (no forced reflow).
365
+ * @private
366
+ */
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;
375
+ const hadDetail = this.hasAttribute('has-detail');
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
+ };
351
400
  }
352
401
 
353
- /** @private */
354
- __stackOverlayChanged(stackOverlay, oldStackOverlay) {
355
- if (stackOverlay || oldStackOverlay) {
356
- this.__detectLayoutMode();
402
+ /**
403
+ * Applies layout state to DOM attributes. Pure writes, no reads.
404
+ * @private
405
+ */
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;
357
416
  }
358
- }
359
417
 
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}`);
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.
421
+ if (!hadDetail && hasDetail && hasOverflow) {
422
+ this.setAttribute('keep-detail-column-offscreen', '');
423
+ } else if (!hasDetail || !hasOverflow) {
424
+ this.removeAttribute('keep-detail-column-offscreen');
366
425
  }
367
426
 
368
- this.toggleAttribute(`has-${prop}`, !!size);
369
- }
427
+ this.toggleAttribute('overlay', hasOverflow);
428
+ this.toggleAttribute('has-master', hasMaster);
429
+ this.toggleAttribute('has-detail', hasDetail);
430
+ this.toggleAttribute('has-detail-placeholder', hasDetailPlaceholder);
370
431
 
371
- /** @private */
372
- __setOverlayMode(value) {
373
- if (this.stackOverlay) {
374
- this._stack = value;
375
- } else {
376
- this._drawer = value;
432
+ // Re-render to update ARIA attributes (role, aria-modal, inert)
433
+ // which depend on has-detail and overlay state.
434
+ this.requestUpdate();
435
+
436
+ if (focusTarget) {
437
+ focusTarget.focus({ preventScroll: true, focusVisible: isKeyboardActive() });
377
438
  }
378
439
  }
379
440
 
380
- /** @private */
381
- __detectLayoutMode() {
382
- this._drawer = false;
383
- this._stack = false;
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];
384
456
 
385
- if (this.forceOverlay) {
386
- this.__setOverlayMode(true);
387
- return;
388
- }
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);
389
462
 
390
- if (!this._hasDetail) {
391
- return;
392
- }
463
+ layout.__detailCachedSize = null;
393
464
 
394
- if (this.orientation === 'vertical') {
395
- this.__detectVerticalMode();
396
- } else {
397
- this.__detectHorizontalMode();
398
- }
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
+ });
399
483
  }
400
484
 
401
485
  /** @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
- }
486
+ get __isDetailAutoSized() {
487
+ return this.detailSize == null;
424
488
  }
425
489
 
426
490
  /** @private */
427
- __detectVerticalMode() {
428
- const masterHeight = this.$.master.clientHeight;
491
+ get __ancestorLayouts() {
492
+ const parent = getClosestElement(this.constructor.is, this.parentNode);
493
+ return parent ? [...parent.__ancestorLayouts, parent] : [];
494
+ }
429
495
 
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);
496
+ /** @private */
497
+ __onBackdropClick() {
498
+ this.dispatchEvent(new CustomEvent('backdrop-click'));
499
+ }
500
+
501
+ /** @private */
502
+ __onDetailKeydown(event) {
503
+ if (event.key === 'Escape' && !event.defaultPrevented) {
504
+ // Prevent firing on parent layout when using nested layouts
505
+ event.preventDefault();
506
+ this.dispatchEvent(new CustomEvent('detail-escape-press'));
434
507
  }
435
508
  }
436
509
 
437
510
  /**
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.
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.
443
516
  *
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.
517
+ * The transition can be skipped using the `skipTransition` parameter or
518
+ * the `noAnimation` property.
447
519
  *
448
520
  * @param element the new detail element, or null to remove the current detail
449
521
  * @param skipTransition whether to skip the transition
450
- * @returns {Promise<void>}
522
+ * @return {Promise<void>}
451
523
  * @protected
452
524
  */
453
- _setDetail(element, skipTransition) {
525
+ async _setDetail(newDetail, skipTransition) {
454
526
  // Don't start a transition if detail didn't change
455
- const currentDetail = this.querySelector('[slot="detail"]');
456
- if ((element || null) === currentDetail) {
457
- return Promise.resolve();
527
+ const oldDetail = this.__slottedDetail;
528
+ if (oldDetail === (newDetail || null)) {
529
+ return;
458
530
  }
459
531
 
460
- const updateSlot = () => {
461
- // Remove old content
462
- this.querySelectorAll('[slot="detail"]').forEach((oldElement) => oldElement.remove());
463
- // Add new content
464
- if (element) {
465
- element.setAttribute('slot', 'detail');
466
- this.appendChild(element);
532
+ const updateSlot = async () => {
533
+ if (oldDetail?.slot === 'detail') {
534
+ oldDetail.remove();
467
535
  }
536
+
537
+ if (newDetail) {
538
+ newDetail.setAttribute('slot', 'detail');
539
+ this.appendChild(newDetail);
540
+ }
541
+
542
+ // Wait for Lit elements to render
543
+ await Promise.resolve();
544
+
545
+ this.recalculateLayout();
468
546
  };
469
547
 
470
- if (skipTransition) {
471
- updateSlot();
472
- return Promise.resolve();
548
+ if (skipTransition || this.noAnimation) {
549
+ await updateSlot();
550
+ return;
473
551
  }
474
552
 
475
- const hasDetail = !!currentDetail;
476
- const transitionType = hasDetail && element ? 'replace' : hasDetail ? 'remove' : 'add';
477
- return this._startTransition(transitionType, () => {
478
- // Update the DOM
479
- updateSlot();
480
- // Finish the transition
481
- this._finishTransition();
482
- });
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
+ }
483
561
  }
484
562
 
485
563
  /**
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.
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).
493
570
  *
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.
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.
497
576
  *
498
577
  * @param transitionType
499
- * @param updateCallback
500
- * @returns {Promise<void>}
578
+ * @param updateSlot
579
+ * @return {Promise<void>}
501
580
  * @protected
502
581
  */
503
- _startTransition(transitionType, updateCallback) {
504
- const useTransition = typeof document.startViewTransition === 'function' && !this.noAnimation;
505
- if (!useTransition) {
506
- updateCallback();
507
- return Promise.resolve();
582
+ async _startTransition(transitionType, updateSlot) {
583
+ if (this.noAnimation) {
584
+ await updateSlot();
585
+ return;
508
586
  }
509
587
 
510
- 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
- });
519
- });
520
- 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
+ }
521
610
  }
522
611
 
523
- /**
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.
527
- *
528
- * @returns {Promise<void>}
529
- * @protected
530
- */
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());
536
-
537
- if (!this.__transition) {
538
- return Promise.resolve();
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
+ }
629
+
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
+ }
539
646
  }
540
- // Resolve the update callback to finish the transition
541
- this.__resolveUpdateCallback();
542
- await this.__transition.finished;
543
- this.removeAttribute('transition');
544
- this.__transition = null;
545
- this.__resolveUpdateCallback = null;
546
647
  }
547
648
 
548
- /**
549
- * @event backdrop-click
550
- * Fired when the user clicks the backdrop in the drawer mode.
551
- */
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
+ }
552
659
 
553
- /**
554
- * @event detail-escape-press
555
- * Fired when the user presses Escape in the detail area.
556
- */
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
+ }
557
674
  }
558
675
 
559
676
  defineCustomElement(MasterDetailLayout);