@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.
@@ -7,189 +7,288 @@ import '@vaadin/component-base/src/styles/style-props.js';
7
7
  import { css } from 'lit';
8
8
 
9
9
  export const masterDetailLayoutStyles = css`
10
- /* Layout and positioning styles */
11
-
10
+ /* stylelint-disable no-duplicate-selectors */
12
11
  :host {
13
- display: flex;
12
+ --_rtl-multiplier: 1;
13
+ --_transition-duration: 0s;
14
+ --_transition-easing: cubic-bezier(0.78, 0, 0.22, 1);
15
+
16
+ display: grid;
14
17
  box-sizing: border-box;
15
18
  height: 100%;
16
- max-width: 100%;
17
- max-height: 100%;
18
- position: relative; /* Keep the positioning context stable across all modes */
19
- z-index: 0; /* Create a new stacking context, don't let "layout contained" detail element stack outside it */
20
- overflow: hidden;
19
+ position: relative;
20
+ overflow: clip;
21
21
  }
22
22
 
23
- :host([hidden]) {
24
- display: none !important;
23
+ :host:not([overlay-containment='page']) {
24
+ z-index: 0;
25
+ }
26
+
27
+ :host([dir='rtl']) {
28
+ --_rtl-multiplier: -1;
29
+ }
30
+
31
+ :host([orientation='horizontal']) {
32
+ --_transition-offset: calc(30px * var(--_rtl-multiplier));
25
33
  }
26
34
 
27
35
  :host([orientation='vertical']) {
28
- flex-direction: column;
36
+ --_transition-offset: 0 30px;
29
37
  }
30
38
 
31
- [part='_detail-internal'] {
32
- display: contents;
33
- justify-content: end;
34
- /* Disable pointer events for the detail wrapper to allow clicks to pass through to the backdrop */
35
- pointer-events: none;
39
+ :host([hidden]),
40
+ ::slotted([hidden]) {
41
+ display: none !important;
36
42
  }
37
43
 
38
- [part='detail'] {
39
- /* Re-enable pointer events for the actual detail content */
40
- pointer-events: auto;
44
+ /* CSS grid template */
45
+
46
+ :host {
47
+ --_master-size: min(100%, 30rem);
48
+ --_master-extra: 0px;
49
+ --_detail-size: var(--_detail-cached-size);
50
+ --_detail-extra: 0px;
51
+ --_detail-cached-size: min-content;
52
+
53
+ /* prettier-ignore */
54
+ --_grid-template:
55
+ [master-start] var(--_master-size) [master-extra] var(--_master-extra)
56
+ [detail-start] var(--_detail-size) [detail-extra] var(--_detail-extra)
57
+ [detail-end];
41
58
  }
42
59
 
43
- :host([orientation='vertical']) [part='_detail-internal'] {
44
- align-items: end;
60
+ :host([force-overlay]) {
61
+ /* prettier-ignore */
62
+ --_grid-template:
63
+ [master-start] var(--_master-size) [master-extra] var(--_master-extra)
64
+ [detail-start] 0px [detail-extra] 0px
65
+ [detail-end];
45
66
  }
46
67
 
47
- :host(:is([drawer], [stack])) [part='_detail-internal'],
48
- :host(:is([drawer], [stack])[has-detail]) [part='backdrop'] {
49
- display: flex;
50
- position: absolute;
51
- z-index: 1;
52
- inset: 0;
53
- overscroll-behavior: contain;
68
+ :host([orientation='horizontal']) {
69
+ grid-template-columns: var(--_grid-template);
70
+ grid-template-rows: 100%;
54
71
  }
55
72
 
56
- :host(:not([has-detail])) [part='_detail-internal'],
57
- [part='backdrop'] {
58
- display: none;
73
+ :host([orientation='vertical']) {
74
+ grid-template-columns: 100%;
75
+ grid-template-rows: var(--_grid-template);
59
76
  }
60
77
 
61
- :host([orientation='horizontal'][drawer]) [part='detail'] {
62
- margin-inline-start: 50px;
78
+ /* CSS grid placement */
79
+
80
+ :host {
81
+ --_master-area: master-start / detail-start;
82
+
83
+ /*
84
+ When the detail size isn't explicitly defined and the detail is set to expand,
85
+ the detail column template is 'min-content 1fr'. In this case, the detail area
86
+ should not span both columns initially (and when recalculating the detail size)
87
+ as spanning both would effectively collapse them into a single '1fr' column where
88
+ min-content resolves to 0, making it impossible to measure the detail's intrinsic
89
+ minimum width from JavaScript.
90
+ */
91
+ --_detail-area: detail-start / detail-extra;
63
92
  }
64
93
 
65
- :host([orientation='vertical'][drawer]) [part='detail'] {
66
- margin-top: 50px;
94
+ :host(:is([has-detail], [has-detail-placeholder]):not([recalculating-detail-size])) {
95
+ --_detail-area: detail-start / detail-end;
67
96
  }
68
97
 
69
- :host(:is([drawer], [stack])[containment='viewport']) :is([part='_detail-internal'], [part='backdrop']) {
70
- position: fixed;
98
+ :host([orientation='horizontal']) #master {
99
+ grid-column: var(--_master-area);
100
+ grid-row: 1;
71
101
  }
72
102
 
73
- :host(:is([drawer], [stack])[containment='viewport']) [part='detail'] {
74
- padding-top: var(--safe-area-inset-top);
75
- padding-bottom: var(--safe-area-inset-bottom);
103
+ :host([orientation='vertical']) #master {
104
+ grid-column: 1;
105
+ grid-row: var(--_master-area);
76
106
  }
77
107
 
78
- :host([containment='viewport']:dir(ltr)) [part='detail'] {
79
- padding-right: var(--safe-area-inset-right);
108
+ :host([orientation='horizontal']) :is(#detail, #detailPlaceholder, #detailOutgoing) {
109
+ grid-column: var(--_detail-area);
110
+ grid-row: 1;
80
111
  }
81
112
 
82
- :host([containment='viewport']:dir(rtl)) [part='detail'] {
83
- padding-left: var(--safe-area-inset-left);
113
+ :host([orientation='vertical']) :is(#detail, #detailPlaceholder, #detailOutgoing) {
114
+ grid-column: 1;
115
+ grid-row: var(--_detail-area);
84
116
  }
85
117
 
86
- :host([stack][containment='viewport']) [part='detail'] {
87
- padding-left: var(--safe-area-inset-left);
88
- padding-right: var(--safe-area-inset-right);
118
+ /* Expand */
119
+
120
+ :host([expand-master]) {
121
+ --_master-extra: 1fr;
89
122
  }
90
123
 
91
- /* Sizing styles */
124
+ :host([expand-detail]) {
125
+ --_detail-extra: 1fr;
126
+ }
92
127
 
93
- [part] {
94
- box-sizing: border-box;
95
- max-width: 100%;
96
- max-height: 100%;
128
+ :host([keep-detail-column-offscreen]),
129
+ :host([has-detail-placeholder][overlay]:not([has-detail])),
130
+ :host(:not([has-detail-placeholder], [has-detail])) {
131
+ --_master-extra: calc(100% - var(--_master-size));
132
+ }
133
+
134
+ /* Backdrop base styles */
135
+
136
+ #backdrop {
137
+ --_transition-easing: linear;
138
+
139
+ position: absolute;
140
+ inset: 0;
141
+ z-index: 2;
142
+ opacity: 0;
143
+ pointer-events: none;
144
+ background: var(--vaadin-overlay-backdrop-background, rgba(0, 0, 0, 0.2));
145
+ forced-color-adjust: none;
97
146
  }
98
147
 
99
- /* No fixed size */
100
- :host(:not([has-master-size])) [part='master'],
101
- :host(:not([has-detail-size]):not([drawer], [stack])) [part='detail'] {
102
- flex-grow: 1;
103
- flex-basis: 50%;
148
+ /* Master base styles */
149
+
150
+ #master {
151
+ opacity: 0;
152
+ pointer-events: none;
153
+ box-sizing: border-box;
104
154
  }
105
155
 
106
- /* Fixed size */
107
- :host([has-master-size]) [part='master'],
108
- :host([has-detail-size]) [part='detail'] {
109
- flex-shrink: 0;
156
+ :host([has-master]) #master {
157
+ opacity: 1;
158
+ pointer-events: auto;
110
159
  }
111
160
 
112
- :host([orientation='horizontal'][has-master-size][has-detail]) [part='master'] {
113
- width: var(--_master-size);
161
+ /* Detail base styles */
162
+
163
+ #detail {
164
+ translate: var(--_transition-offset);
165
+ opacity: 0;
166
+ z-index: 4;
114
167
  }
115
168
 
116
- :host([orientation='vertical'][has-master-size][has-detail]) [part='master'] {
117
- height: var(--_master-size);
169
+ :host([has-detail]) #detail {
170
+ translate: none;
171
+ opacity: 1;
118
172
  }
119
173
 
120
- :host([orientation='horizontal'][has-detail-size]:not([stack])) [part='detail'] {
121
- width: var(--_detail-size);
174
+ #detailOutgoing {
175
+ position: absolute;
176
+ z-index: 3;
177
+ display: none;
122
178
  }
123
179
 
124
- :host([orientation='vertical'][has-detail-size]:not([stack])) [part='detail'] {
125
- height: var(--_detail-size);
180
+ :host([transition='replace']) #detailOutgoing {
181
+ display: block;
126
182
  }
127
183
 
128
- :host([has-master-size][has-detail-size]) [part='master'] {
129
- flex-grow: 1;
130
- flex-basis: var(--_master-size);
184
+ #detailPlaceholder {
185
+ z-index: 1;
186
+ opacity: 0;
187
+ pointer-events: none;
131
188
  }
132
189
 
133
- :host([has-master-size][has-detail-size]:not([drawer], [stack])) [part='detail'] {
134
- flex-grow: 1;
135
- flex-basis: var(--_detail-size);
190
+ :host([has-detail-placeholder]:not([has-detail], [overlay])) #detailPlaceholder {
191
+ opacity: 1;
192
+ pointer-events: auto;
136
193
  }
137
194
 
138
- /* Min size */
139
- :host([orientation='horizontal'][has-master-min-size]) [part='master'] {
140
- min-width: min(100%, var(--_master-min-size));
195
+ :is(#detail, #detailPlaceholder, #detailOutgoing) {
196
+ box-sizing: border-box;
141
197
  }
142
198
 
143
- :host([orientation='vertical'][has-master-min-size]) [part='master'] {
144
- min-height: min(100%, var(--_master-min-size));
199
+ /* Detail borders */
200
+
201
+ #detail,
202
+ #detailPlaceholder {
203
+ border-color: var(--vaadin-master-detail-layout-border-color, var(--vaadin-border-color-secondary));
204
+ border-width: var(--vaadin-master-detail-layout-border-width, 1px);
145
205
  }
146
206
 
147
- :host([orientation='horizontal'][has-detail-min-size]) [part='detail'] {
148
- min-width: min(100%, var(--_detail-min-size));
207
+ :host([orientation='horizontal']) #detailPlaceholder,
208
+ :host([orientation='horizontal']:not([overlay])) #detail {
209
+ border-inline-start-style: solid;
149
210
  }
150
211
 
151
- :host([orientation='vertical'][has-detail-min-size]) [part='detail'] {
152
- min-height: min(100%, var(--_detail-min-size));
212
+ :host([orientation='vertical']) #detailPlaceholder,
213
+ :host([orientation='vertical']:not([overlay])) #detail {
214
+ border-block-start-style: solid;
153
215
  }
154
216
 
155
- :host([drawer]) [part='master'],
156
- :host([stack]) [part] {
157
- width: 100% !important;
158
- height: 100% !important;
159
- min-width: auto !important;
160
- min-height: auto !important;
161
- max-width: 100% !important;
162
- max-height: 100% !important;
217
+ /* Overlay */
218
+
219
+ :host([overlay][orientation='horizontal']) {
220
+ --_transition-offset: calc((100% + 30px) * var(--_rtl-multiplier));
163
221
  }
164
222
 
165
- /* Decorative/visual styles */
223
+ :host([overlay][orientation='vertical']) {
224
+ --_transition-offset: 0 calc(100% + 30px);
225
+ }
166
226
 
167
- [part='backdrop'] {
168
- background: var(--vaadin-overlay-backdrop-background, rgba(0, 0, 0, 0.2));
169
- forced-color-adjust: none;
227
+ :host([has-detail][overlay]) #backdrop {
228
+ opacity: 1;
229
+ pointer-events: auto;
170
230
  }
171
231
 
172
- :host(:is([drawer], [stack])) [part='detail'] {
232
+ :host([has-detail][overlay]) :is(#detail, #detailOutgoing) {
233
+ position: absolute;
173
234
  background: var(--vaadin-master-detail-layout-detail-background, var(--vaadin-background-color));
174
235
  box-shadow: var(--vaadin-master-detail-layout-detail-shadow, 0 0 20px 0 rgba(0, 0, 0, 0.3));
236
+ grid-column: none;
237
+ grid-row: none;
175
238
  }
176
239
 
177
- :host([orientation='horizontal']:not([drawer], [stack])) [part='detail'] {
178
- border-inline-start: var(--vaadin-master-detail-layout-border-width, 1px) solid
179
- var(--vaadin-master-detail-layout-border-color, var(--vaadin-border-color-secondary));
240
+ :host([has-detail][overlay][orientation='horizontal']) :is(#detail, #detailOutgoing) {
241
+ inset-block: 0;
242
+ inset-inline-end: 0;
243
+ width: var(--_overlay-size, var(--_detail-size));
244
+ max-width: 100%;
180
245
  }
181
246
 
182
- :host([orientation='vertical']:not([drawer], [stack])) [part='detail'] {
183
- border-top: var(--vaadin-master-detail-layout-border-width, 1px) solid
184
- var(--vaadin-master-detail-layout-border-color, var(--vaadin-border-color-secondary));
247
+ :host([has-detail][overlay][orientation='vertical']) :is(#detail, #detailOutgoing) {
248
+ inset-inline: 0;
249
+ inset-block-end: 0;
250
+ height: var(--_overlay-size, var(--_detail-size));
251
+ max-height: 100%;
185
252
  }
186
253
 
254
+ :host([has-detail][overlay][overlay-containment='page']) :is(#detail, #detailOutgoing, #backdrop) {
255
+ position: fixed;
256
+ padding-top: env(safe-area-inset-top);
257
+ padding-bottom: env(safe-area-inset-bottom);
258
+ padding-right: env(safe-area-inset-right);
259
+ --safe-area-inset-top: 0px;
260
+ --safe-area-inset-bottom: 0px;
261
+ --safe-area-inset-left: 0px;
262
+ --safe-area-inset-right: 0px;
263
+ --safe-area-inset-inline-start: 0px;
264
+ --safe-area-inset-inline-end: 0px;
265
+ }
266
+
267
+ :host([dir='rtl'][has-detail][overlay][overlay-containment='page']) :is(#detail, #detailOutgoing, #backdrop) {
268
+ padding-right: 0;
269
+ padding-left: env(safe-area-inset-left);
270
+ }
271
+
272
+ /* Transitions */
273
+
274
+ @media (prefers-reduced-motion: no-preference) {
275
+ :host(:not([no-animation], [transition='replace'])) {
276
+ --_transition-duration: 200ms;
277
+ }
278
+
279
+ :host([overlay]:not([no-animation])) {
280
+ --_transition-duration: 300ms;
281
+ }
282
+ }
283
+
284
+ /* Forced colors */
285
+
187
286
  @media (forced-colors: active) {
188
- :host(:is([drawer], [stack])) [part='detail'] {
287
+ :host([has-detail][overlay]) :is(#detail, #detailOutgoing) {
189
288
  outline: 3px solid !important;
190
289
  }
191
290
 
192
- [part='detail'] {
291
+ :is(#detail, #detailPlaceholder, #detailOutgoing) {
193
292
  background: Canvas !important;
194
293
  }
195
294
  }
@@ -0,0 +1,173 @@
1
+ /**
2
+ * @license
3
+ * Copyright (c) 2025 - 2026 Vaadin Ltd.
4
+ * This program is available under Apache License Version 2.0, available at https://vaadin.com/license/
5
+ */
6
+
7
+ const ANIMATION_ID = 'vaadin-master-detail-layout';
8
+
9
+ /**
10
+ * Reads CSS custom properties from the element that control
11
+ * animation keyframes and timing.
12
+ *
13
+ * @param {HTMLElement} element
14
+ * @return {{ offset: string, easing: string, duration: number }}
15
+ */
16
+ function getAnimationParams(element) {
17
+ const computedStyle = getComputedStyle(element);
18
+ const offset = computedStyle.getPropertyValue('--_transition-offset');
19
+ const easing = computedStyle.getPropertyValue('--_transition-easing');
20
+ const durationStr = computedStyle.getPropertyValue('--_transition-duration');
21
+ const duration = durationStr.endsWith('ms') ? parseFloat(durationStr) : parseFloat(durationStr) * 1000;
22
+ return { offset, easing, duration };
23
+ }
24
+
25
+ /**
26
+ * Returns the currently running master-detail-layout animation on the
27
+ * element, if any. Matches by the shared animation ID and `'running'`
28
+ * play state.
29
+ *
30
+ * @param {HTMLElement} element
31
+ * @return {Animation | undefined}
32
+ */
33
+ export function getCurrentAnimation(element) {
34
+ return element
35
+ .getAnimations()
36
+ .find((animation) => animation.id === ANIMATION_ID && animation.playState !== 'finished');
37
+ }
38
+
39
+ /**
40
+ * Returns the overall progress (0–1) of the current animation on the
41
+ * element, computed as `currentTime / duration`. Returns 0 when no
42
+ * animation is running.
43
+ *
44
+ * @param {HTMLElement} element
45
+ * @return {number}
46
+ */
47
+ export function getCurrentAnimationProgress(element) {
48
+ const animation = getCurrentAnimation(element);
49
+ if (!animation) {
50
+ return 0;
51
+ }
52
+ const currentTime = animation.currentTime;
53
+ if (currentTime == null) {
54
+ return 0;
55
+ }
56
+ return currentTime / animation.effect.getTiming().duration;
57
+ }
58
+
59
+ /**
60
+ * Animates the element using the Web Animations API. Cancels any
61
+ * previous animation and resumes from the given progress for a
62
+ * smooth handoff. No-op when CSS params are missing or progress is 1.
63
+ *
64
+ * @param {HTMLElement} element
65
+ * @param {'in' | 'out'} direction
66
+ * @param {Array<'fade' | 'slide'>} effects
67
+ * @param {number} progress starting progress (0–1) for interrupted resumption
68
+ * @return {Promise<void>} resolves when the animation finishes
69
+ */
70
+ function animate(element, direction, effects, progress) {
71
+ const { offset, easing, duration } = getAnimationParams(element);
72
+ if (!offset || !duration || progress === 1) {
73
+ return Promise.resolve();
74
+ }
75
+
76
+ const oldAnimation = getCurrentAnimation(element);
77
+ if (oldAnimation) {
78
+ oldAnimation.cancel();
79
+ }
80
+
81
+ const keyframes = {};
82
+ if (effects.includes('fade')) {
83
+ keyframes.opacity = [0, 1];
84
+ }
85
+ if (effects.includes('slide')) {
86
+ keyframes.translate = [offset, 0];
87
+ }
88
+
89
+ const newAnimation = element.animate(keyframes, { id: ANIMATION_ID, easing, duration });
90
+ newAnimation.pause();
91
+ newAnimation.currentTime = duration * progress;
92
+ newAnimation.playbackRate = direction === 'in' ? 1 : -1;
93
+ newAnimation.play();
94
+ return newAnimation.finished;
95
+ }
96
+
97
+ /**
98
+ * Runs an enter animation on the element.
99
+ *
100
+ * @param {HTMLElement} element
101
+ * @param {Array<'fade' | 'slide'>} effects
102
+ * @param {number} progress starting progress (0–1) for interrupted resumption
103
+ * @return {Promise<void>} resolves when the animation finishes
104
+ */
105
+ export function animateIn(element, effects, progress) {
106
+ return animate(element, 'in', effects, progress);
107
+ }
108
+
109
+ /**
110
+ * Runs an exit animation on the element.
111
+ *
112
+ * @param {HTMLElement} element
113
+ * @param {Array<'fade' | 'slide'>} effects
114
+ * @param {number} progress starting progress (0–1) for interrupted resumption
115
+ * @return {Promise<void>} resolves when the animation finishes
116
+ */
117
+ export function animateOut(element, effects, progress) {
118
+ return animate(element, 'out', effects, progress);
119
+ }
120
+
121
+ /**
122
+ * Cancels all running animations on the element that match the shared animation ID.
123
+ *
124
+ * @param {HTMLElement} element
125
+ */
126
+ export function cancelAnimations(element) {
127
+ element.getAnimations({ subtree: true }).forEach((animation) => {
128
+ if (animation.id === ANIMATION_ID) {
129
+ animation.cancel();
130
+ }
131
+ });
132
+ }
133
+
134
+ /**
135
+ * Parses a computed `gridTemplateColumns` / `gridTemplateRows` value
136
+ * into an array of track sizes in pixels. Line names (e.g. `[name]`)
137
+ * are stripped before parsing.
138
+ *
139
+ * @param {string} gridTemplate computed grid template string (e.g. `"200px [gap] 10px 400px"`)
140
+ * @return {number[]} track sizes in pixels
141
+ */
142
+ export function parseTrackSizes(gridTemplate) {
143
+ return gridTemplate
144
+ .replace(/\[[^\]]+\]/gu, '')
145
+ .replace(/\s+/gu, ' ')
146
+ .trim()
147
+ .split(' ')
148
+ .map(parseFloat);
149
+ }
150
+
151
+ /**
152
+ * Determines whether the detail area overflows the host element,
153
+ * meaning it should be shown as an overlay instead of side-by-side.
154
+ *
155
+ * Returns `false` when all tracks fit within the host, or when the
156
+ * master's extra space (flexible portion) is large enough to absorb
157
+ * the detail column.
158
+ *
159
+ * @param {number} hostSize the host element's width or height in pixels
160
+ * @param {number[]} trackSizes [masterSize, masterExtra, detailSize] in pixels
161
+ * @return {boolean} `true` if the detail overflows and should be overlaid
162
+ */
163
+ export function detectOverflow(hostSize, trackSizes) {
164
+ const [masterSize, masterExtra, detailSize] = trackSizes;
165
+
166
+ if (Math.floor(masterSize + masterExtra + detailSize) <= Math.floor(hostSize)) {
167
+ return false;
168
+ }
169
+ if (Math.floor(masterExtra) >= Math.floor(detailSize)) {
170
+ return false;
171
+ }
172
+ return true;
173
+ }