@vaadin/master-detail-layout 25.1.0-beta4 → 25.2.0-alpha1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -8,12 +8,20 @@ import { getFocusableElements } from '@vaadin/a11y-base/src/focus-utils.js';
8
8
  import { defineCustomElement } from '@vaadin/component-base/src/define.js';
9
9
  import { ElementMixin } from '@vaadin/component-base/src/element-mixin.js';
10
10
  import { PolylitMixin } from '@vaadin/component-base/src/polylit-mixin.js';
11
- import { ResizeMixin } from '@vaadin/component-base/src/resize-mixin.js';
12
11
  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
14
  import { masterDetailLayoutTransitionStyles } from './styles/vaadin-master-detail-layout-transition-base-styles.js';
16
15
 
16
+ function parseTrackSizes(gridTemplate) {
17
+ return gridTemplate
18
+ .replace(/\[[^\]]+\]/gu, '')
19
+ .replace(/\s+/gu, ' ')
20
+ .trim()
21
+ .split(' ')
22
+ .map(parseFloat);
23
+ }
24
+
17
25
  /**
18
26
  * `<vaadin-master-detail-layout>` is a web component for building UIs with a master
19
27
  * (or primary) area and a detail (or secondary) area that is displayed next to, or
@@ -25,19 +33,19 @@ import { masterDetailLayoutTransitionStyles } from './styles/vaadin-master-detai
25
33
  *
26
34
  * Part name | Description
27
35
  * ---------------|----------------------
28
- * `backdrop` | Backdrop covering the master area in the drawer mode
36
+ * `backdrop` | Backdrop covering the master area in the overlay mode
29
37
  * `master` | The master area
30
38
  * `detail` | The detail area
31
39
  *
32
40
  * The following state attributes are available for styling:
33
41
  *
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.
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`.
41
49
  *
42
50
  * The following custom CSS properties are available for styling:
43
51
  *
@@ -51,17 +59,16 @@ import { masterDetailLayoutTransitionStyles } from './styles/vaadin-master-detai
51
59
  *
52
60
  * See [Styling Components](https://vaadin.com/docs/latest/styling/styling-components) documentation.
53
61
  *
54
- * @fires {CustomEvent} backdrop-click - Fired when the user clicks the backdrop in the drawer mode.
62
+ * @fires {CustomEvent} backdrop-click - Fired when the user clicks the backdrop in the overlay mode.
55
63
  * @fires {CustomEvent} detail-escape-press - Fired when the user presses Escape in the detail area.
56
64
  *
57
65
  * @customElement vaadin-master-detail-layout
58
66
  * @extends HTMLElement
59
67
  * @mixes ThemableMixin
60
68
  * @mixes ElementMixin
61
- * @mixes ResizeMixin
62
69
  * @mixes SlotStylesMixin
63
70
  */
64
- class MasterDetailLayout extends SlotStylesMixin(ResizeMixin(ElementMixin(ThemableMixin(PolylitMixin(LitElement))))) {
71
+ class MasterDetailLayout extends SlotStylesMixin(ElementMixin(ThemableMixin(PolylitMixin(LitElement)))) {
65
72
  static get is() {
66
73
  return 'vaadin-master-detail-layout';
67
74
  }
@@ -73,11 +80,10 @@ class MasterDetailLayout extends SlotStylesMixin(ResizeMixin(ElementMixin(Themab
73
80
  static get properties() {
74
81
  return {
75
82
  /**
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.
83
+ * 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
85
+ * master and detail areas next to each other, the detail area
86
+ * is shown as an overlay. Defaults to 15em.
81
87
  *
82
88
  * @attr {string} detail-size
83
89
  */
@@ -88,26 +94,10 @@ class MasterDetailLayout extends SlotStylesMixin(ResizeMixin(ElementMixin(Themab
88
94
  },
89
95
 
90
96
  /**
91
- * Minimum size (in CSS length units) to be set on the detail area.
92
- * When specified, it prevents the detail area from shrinking below
93
- * this size. If there is not enough space to show master and detail
94
- * areas next to each other, the details are shown as an overlay:
95
- * either as drawer or stack, depending on the `stackOverlay` property.
96
- *
97
- * @attr {string} detail-min-size
98
- */
99
- detailMinSize: {
100
- type: String,
101
- sync: true,
102
- observer: '__detailMinSizeChanged',
103
- },
104
-
105
- /**
106
- * Fixed size (in CSS length units) to be set on the master area.
107
- * When specified, it prevents the master area from growing or
108
- * shrinking. If there is not enough space to show master and detail
109
- * areas next to each other, the details are shown as an overlay:
110
- * either as drawer or stack, depending on the `stackOverlay` property.
97
+ * Size (in CSS length units) to be set on the master area in
98
+ * the CSS grid layout. If there is not enough space to show
99
+ * master and detail areas next to each other, the detail area
100
+ * is shown as an overlay. Defaults to 30em.
111
101
  *
112
102
  * @attr {string} master-size
113
103
  */
@@ -118,18 +108,16 @@ class MasterDetailLayout extends SlotStylesMixin(ResizeMixin(ElementMixin(Themab
118
108
  },
119
109
 
120
110
  /**
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.
111
+ * Size (in CSS length units) for the detail area when shown as an
112
+ * overlay. When not set, falls back to `detailSize`. Set to `100%`
113
+ * to make the detail cover the full layout.
126
114
  *
127
- * @attr {string} master-min-size
115
+ * @attr {string} overlay-size
128
116
  */
129
- masterMinSize: {
117
+ overlaySize: {
130
118
  type: String,
131
119
  sync: true,
132
- observer: '__masterMinSizeChanged',
120
+ observer: '__overlaySizeChanged',
133
121
  },
134
122
 
135
123
  /**
@@ -142,25 +130,6 @@ class MasterDetailLayout extends SlotStylesMixin(ResizeMixin(ElementMixin(Themab
142
130
  type: String,
143
131
  value: 'horizontal',
144
132
  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
133
  sync: true,
165
134
  },
166
135
 
@@ -169,8 +138,10 @@ class MasterDetailLayout extends SlotStylesMixin(ResizeMixin(ElementMixin(Themab
169
138
  * overlay mode. When set to `layout`, the overlay is confined to the
170
139
  * layout. When set to `viewport`, the overlay is confined to the
171
140
  * browser's viewport. Defaults to `layout`.
141
+ *
142
+ * @attr {string} overlay-containment
172
143
  */
173
- containment: {
144
+ overlayContainment: {
174
145
  type: String,
175
146
  value: 'layout',
176
147
  reflectToAttribute: true,
@@ -178,19 +149,14 @@ class MasterDetailLayout extends SlotStylesMixin(ResizeMixin(ElementMixin(Themab
178
149
  },
179
150
 
180
151
  /**
181
- * When true, the layout in the overlay mode is rendered as a stack,
182
- * making detail area fully cover the master area. Otherwise, it is
183
- * rendered as a drawer and has a visual backdrop.
184
- *
185
- * In order to enforce the stack mode, use this property together with
186
- * `forceOverlay` property and set both to `true`.
187
- *
188
- * @attr {string} stack-threshold
152
+ * Controls which column(s) expand to fill available space.
153
+ * Possible values: `'master'`, `'detail'`, `'both'`.
154
+ * Defaults to `'both'`.
189
155
  */
190
- stackOverlay: {
191
- type: Boolean,
192
- value: false,
193
- observer: '__stackOverlayChanged',
156
+ expand: {
157
+ type: String,
158
+ value: 'both',
159
+ reflectToAttribute: true,
194
160
  sync: true,
195
161
  },
196
162
 
@@ -203,39 +169,6 @@ class MasterDetailLayout extends SlotStylesMixin(ResizeMixin(ElementMixin(Themab
203
169
  type: Boolean,
204
170
  value: false,
205
171
  },
206
-
207
- /**
208
- * When true, the component uses the drawer mode. This property is read-only.
209
- * @protected
210
- */
211
- _drawer: {
212
- type: Boolean,
213
- attribute: 'drawer',
214
- reflectToAttribute: true,
215
- sync: true,
216
- },
217
-
218
- /**
219
- * When true, the component uses the stack mode. This property is read-only.
220
- * @protected
221
- */
222
- _stack: {
223
- type: Boolean,
224
- attribute: 'stack',
225
- reflectToAttribute: true,
226
- sync: true,
227
- },
228
-
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,
237
- sync: true,
238
- },
239
172
  };
240
173
  }
241
174
 
@@ -243,194 +176,168 @@ class MasterDetailLayout extends SlotStylesMixin(ResizeMixin(ElementMixin(Themab
243
176
  return true;
244
177
  }
245
178
 
246
- /** @override */
179
+ /** @return {!Array<!CSSResult>} */
247
180
  get slotStyles() {
248
181
  return [masterDetailLayoutTransitionStyles];
249
182
  }
250
183
 
251
184
  /** @protected */
252
185
  render() {
186
+ const isOverlay = this.hasAttribute('has-detail') && this.hasAttribute('overflow');
187
+ const isViewport = isOverlay && this.overlayContainment === 'viewport';
188
+ const isLayoutContained = isOverlay && !isViewport;
189
+
253
190
  return html`
254
191
  <div part="backdrop" @click="${this.__onBackdropClick}"></div>
192
+ <div id="master" part="master" ?inert="${isLayoutContained}">
193
+ <slot @slotchange="${this.__onSlotChange}"></slot>
194
+ </div>
255
195
  <div
256
- id="master"
257
- part="master"
258
- ?inert="${this._hasDetail && (this._stack || (this._drawer && this.containment === 'layout'))}"
196
+ id="detail"
197
+ part="detail"
198
+ role="${isOverlay ? 'dialog' : nothing}"
199
+ aria-modal="${isViewport ? 'true' : nothing}"
200
+ @keydown="${this.__onDetailKeydown}"
259
201
  >
260
- <slot></slot>
261
- </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>
202
+ <slot name="detail" @slotchange="${this.__onSlotChange}"></slot>
272
203
  </div>
273
204
  `;
274
205
  }
275
206
 
276
- /** @private */
277
- __onDetailSlotChange(e) {
278
- const children = e.target.assignedNodes();
279
-
280
- this._hasDetail = children.length > 0;
281
- this.__detectLayoutMode();
282
-
283
- // Move focus to the detail area when it is added to the DOM,
284
- // in case if the layout is using drawer or stack mode.
285
- if ((this._drawer || this._stack) && children.length > 0) {
286
- const focusables = getFocusableElements(children[0]);
287
- if (focusables.length) {
288
- focusables[0].focus();
289
- }
290
- }
207
+ /** @protected */
208
+ connectedCallback() {
209
+ super.connectedCallback();
210
+ this.__initResizeObserver();
291
211
  }
292
212
 
293
- /** @private */
294
- __onBackdropClick() {
295
- this.dispatchEvent(new CustomEvent('backdrop-click'));
213
+ /** @protected */
214
+ disconnectedCallback() {
215
+ super.disconnectedCallback();
216
+ this.__resizeObserver.disconnect();
217
+ cancelAnimationFrame(this.__resizeRaf);
296
218
  }
297
219
 
298
220
  /** @private */
299
- __onDetailKeydown(event) {
300
- if (event.key === 'Escape' && !event.defaultPrevented) {
301
- // Prevent firing on parent layout when using nested layouts
302
- event.preventDefault();
303
- this.dispatchEvent(new CustomEvent('detail-escape-press'));
304
- }
305
- }
306
-
307
- /**
308
- * @protected
309
- * @override
310
- */
311
- _onResize() {
312
- this.__detectLayoutMode();
221
+ __masterSizeChanged(size, oldSize) {
222
+ this.__updateStyleProperty('master-size', size, oldSize);
313
223
  }
314
224
 
315
225
  /** @private */
316
226
  __detailSizeChanged(size, oldSize) {
317
227
  this.__updateStyleProperty('detail-size', size, oldSize);
318
- this.__detectLayoutMode();
319
228
  }
320
229
 
321
230
  /** @private */
322
- __detailMinSizeChanged(size, oldSize) {
323
- this.__updateStyleProperty('detail-min-size', size, oldSize);
324
- this.__detectLayoutMode();
231
+ __overlaySizeChanged(size, oldSize) {
232
+ this.__updateStyleProperty('overlay-size', size, oldSize);
325
233
  }
326
234
 
327
235
  /** @private */
328
- __masterSizeChanged(size, oldSize) {
329
- this.__updateStyleProperty('master-size', size, oldSize);
330
- this.__detectLayoutMode();
236
+ __updateStyleProperty(prop, size, oldSize) {
237
+ if (size) {
238
+ this.style.setProperty(`--_${prop}`, size);
239
+ } else if (oldSize) {
240
+ this.style.removeProperty(`--_${prop}`);
241
+ }
331
242
  }
332
243
 
333
244
  /** @private */
334
- __masterMinSizeChanged(size, oldSize) {
335
- this.__updateStyleProperty('master-min-size', size, oldSize);
336
- this.__detectLayoutMode();
245
+ __onSlotChange() {
246
+ this.__initResizeObserver();
337
247
  }
338
248
 
339
249
  /** @private */
340
- __orientationChanged(orientation, oldOrientation) {
341
- if (orientation || oldOrientation) {
342
- this.__detectLayoutMode();
343
- }
250
+ __initResizeObserver() {
251
+ this.__resizeObserver = this.__resizeObserver || new ResizeObserver(() => this.__onResize());
252
+ this.__resizeObserver.disconnect();
253
+
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);
257
+ });
344
258
  }
345
259
 
346
- /** @private */
347
- __forceOverlayChanged(forceOverlay, oldForceOverlay) {
348
- if (forceOverlay || oldForceOverlay) {
349
- this.__detectLayoutMode();
350
- }
260
+ /**
261
+ * Called by the ResizeObserver. Reads layout state synchronously (no forced
262
+ * reflow since layout is already computed), then defers writes to rAF.
263
+ * Cancels any pending rAF so the write phase always uses the latest state.
264
+ * @private
265
+ */
266
+ __onResize() {
267
+ const state = this.__computeLayoutState();
268
+ cancelAnimationFrame(this.__resizeRaf);
269
+ this.__resizeRaf = requestAnimationFrame(() => this.__applyLayoutState(state));
351
270
  }
352
271
 
353
- /** @private */
354
- __stackOverlayChanged(stackOverlay, oldStackOverlay) {
355
- if (stackOverlay || oldStackOverlay) {
356
- this.__detectLayoutMode();
357
- }
272
+ /**
273
+ * Reads DOM/style state needed for layout detection. Safe to call in
274
+ * ResizeObserver callback where layout is already computed (no forced reflow).
275
+ * @private
276
+ */
277
+ __computeLayoutState() {
278
+ const detailContent = this.querySelector('[slot="detail"]');
279
+ 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 };
358
284
  }
359
285
 
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}`);
286
+ /**
287
+ * Applies layout state to DOM attributes. Pure writes, no reads.
288
+ * @private
289
+ */
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.
293
+ if (!hadDetail && hasDetail && hasOverflow) {
294
+ this.setAttribute('keep-detail-column-offscreen', '');
295
+ } else if (!hasDetail || !hasOverflow) {
296
+ this.removeAttribute('keep-detail-column-offscreen');
366
297
  }
367
298
 
368
- this.toggleAttribute(`has-${prop}`, !!size);
369
- }
299
+ this.toggleAttribute('has-detail', hasDetail);
300
+ this.toggleAttribute('overflow', hasOverflow);
370
301
 
371
- /** @private */
372
- __setOverlayMode(value) {
373
- if (this.stackOverlay) {
374
- this._stack = value;
375
- } else {
376
- this._drawer = value;
302
+ // Re-render to update ARIA attributes (role, aria-modal, inert)
303
+ // which depend on has-detail and overflow state.
304
+ this.requestUpdate();
305
+
306
+ if (focusTarget) {
307
+ focusTarget.focus();
377
308
  }
378
309
  }
379
310
 
380
311
  /** @private */
381
- __detectLayoutMode() {
382
- this._drawer = false;
383
- this._stack = false;
312
+ __checkOverflow() {
313
+ const isVertical = this.orientation === 'vertical';
314
+ const computedStyle = getComputedStyle(this);
384
315
 
385
- if (this.forceOverlay) {
386
- this.__setOverlayMode(true);
387
- return;
388
- }
316
+ const hostSize = parseFloat(computedStyle[isVertical ? 'height' : 'width']);
317
+ const [masterSize, masterExtra, detailSize] = parseTrackSizes(
318
+ computedStyle[isVertical ? 'gridTemplateRows' : 'gridTemplateColumns'],
319
+ );
389
320
 
390
- if (!this._hasDetail) {
391
- return;
321
+ if (Math.floor(masterSize + masterExtra + detailSize) <= Math.floor(hostSize)) {
322
+ return false;
392
323
  }
393
-
394
- if (this.orientation === 'vertical') {
395
- this.__detectVerticalMode();
396
- } else {
397
- this.__detectHorizontalMode();
324
+ if (Math.floor(masterExtra) >= Math.floor(detailSize)) {
325
+ return false;
398
326
  }
327
+ return true;
399
328
  }
400
329
 
401
330
  /** @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
- }
331
+ __onBackdropClick() {
332
+ this.dispatchEvent(new CustomEvent('backdrop-click'));
424
333
  }
425
334
 
426
335
  /** @private */
427
- __detectVerticalMode() {
428
- const masterHeight = this.$.master.clientHeight;
429
-
430
- // If the combined minimum size of both the master and the detail content
431
- // exceeds the available height, the layout changes to the overlay mode.
432
- if (this.offsetHeight < masterHeight + this.$.detail.clientHeight) {
433
- this.__setOverlayMode(true);
336
+ __onDetailKeydown(event) {
337
+ if (event.key === 'Escape' && !event.defaultPrevented) {
338
+ // Prevent firing on parent layout when using nested layouts
339
+ event.preventDefault();
340
+ this.dispatchEvent(new CustomEvent('detail-escape-press'));
434
341
  }
435
342
  }
436
343
 
@@ -529,10 +436,13 @@ class MasterDetailLayout extends SlotStylesMixin(ResizeMixin(ElementMixin(Themab
529
436
  * @protected
530
437
  */
531
438
  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());
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
+ });
536
446
 
537
447
  if (!this.__transition) {
538
448
  return Promise.resolve();
@@ -547,7 +457,7 @@ class MasterDetailLayout extends SlotStylesMixin(ResizeMixin(ElementMixin(Themab
547
457
 
548
458
  /**
549
459
  * @event backdrop-click
550
- * Fired when the user clicks the backdrop in the drawer mode.
460
+ * Fired when the user clicks the backdrop in the overlay mode.
551
461
  */
552
462
 
553
463
  /**