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