Skip to content

Commit 91893af

Browse files
authored
feat: add stackThreshold property to master-detail-layout (#8828)
1 parent 26c6801 commit 91893af

File tree

5 files changed

+243
-8
lines changed

5 files changed

+243
-8
lines changed

dev/master-detail-layout.html

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@
2323
background-color: var(--lumo-shade-20pct);
2424
}
2525

26-
vaadin-master-detail-layout[overlay]::part(detail) {
26+
vaadin-master-detail-layout:is([overlay], [stack])::part(detail) {
2727
background: #fff;
2828
}
2929

@@ -47,6 +47,7 @@
4747
<vaadin-checkbox id="maxWidth" label="Use max-width on the host"></vaadin-checkbox>
4848
<vaadin-checkbox id="maxHeight" label="Use max-height on the host"></vaadin-checkbox>
4949
<vaadin-checkbox id="forceOverlay" label="Force overlay"></vaadin-checkbox>
50+
<vaadin-checkbox id="stack" label="Set stack threshold"></vaadin-checkbox>
5051
</p>
5152

5253
<vaadin-master-detail-layout>
@@ -114,6 +115,10 @@
114115
document.querySelector('#forceOverlay').addEventListener('change', (e) => {
115116
layout.forceOverlay = e.target.checked;
116117
});
118+
119+
document.querySelector('#stack').addEventListener('change', (e) => {
120+
layout.stackThreshold = e.target.checked ? '30rem' : null;
121+
});
117122
</script>
118123
</body>
119124
</html>

packages/master-detail-layout/src/vaadin-master-detail-layout.d.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,14 @@ declare class MasterDetailLayout extends ResizeMixin(ThemableMixin(ElementMixin(
7777
* browser's viewport. Defaults to `layout`.
7878
*/
7979
containment: 'layout' | 'viewport';
80+
81+
/**
82+
* The threshold (in CSS length units) at which the layout switches to
83+
* the "stack" mode, making detail area fully cover the master area.
84+
*
85+
* @attr {string} stack-threshold
86+
*/
87+
stackThreshold: string | null | undefined;
8088
}
8189

8290
declare global {

packages/master-detail-layout/src/vaadin-master-detail-layout.js

Lines changed: 65 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -43,15 +43,15 @@ class MasterDetailLayout extends ResizeMixin(ElementMixin(ThemableMixin(PolylitM
4343
}
4444
4545
/* Overlay mode */
46-
:host([overlay][has-detail]) {
46+
:host(:is([overlay], [stack])) {
4747
position: relative;
4848
}
4949
50-
:host([overlay][containment='layout']) [part='detail'] {
50+
:host(:is([overlay], [stack])[containment='layout']) [part='detail'] {
5151
position: absolute;
5252
}
5353
54-
:host([overlay][containment='viewport']) [part='detail'] {
54+
:host(:is([overlay], [stack])[containment='viewport']) [part='detail'] {
5555
position: fixed;
5656
}
5757
@@ -87,7 +87,7 @@ class MasterDetailLayout extends ResizeMixin(ElementMixin(ThemableMixin(PolylitM
8787
width: var(--_master-size);
8888
}
8989
90-
:host([has-detail-size][orientation='horizontal']) [part='detail'] {
90+
:host([has-detail-size][orientation='horizontal']:not([stack])) [part='detail'] {
9191
width: var(--_detail-size);
9292
}
9393
@@ -106,7 +106,7 @@ class MasterDetailLayout extends ResizeMixin(ElementMixin(ThemableMixin(PolylitM
106106
min-width: var(--_master-min-size);
107107
}
108108
109-
:host([has-detail-min-size][orientation='horizontal']:not([overlay])) [part='detail'] {
109+
:host([has-detail-min-size][orientation='horizontal']:not([overlay]):not([stack])) [part='detail'] {
110110
min-width: var(--_detail-min-size);
111111
}
112112
@@ -139,7 +139,7 @@ class MasterDetailLayout extends ResizeMixin(ElementMixin(ThemableMixin(PolylitM
139139
height: var(--_master-size);
140140
}
141141
142-
:host([has-detail-size][orientation='vertical']) [part='detail'] {
142+
:host([has-detail-size][orientation='vertical']:not([stack])) [part='detail'] {
143143
height: var(--_detail-size);
144144
}
145145
@@ -149,9 +149,22 @@ class MasterDetailLayout extends ResizeMixin(ElementMixin(ThemableMixin(PolylitM
149149
min-height: var(--_master-min-size);
150150
}
151151
152-
:host([has-detail-min-size][orientation='vertical']:not([overlay])) [part='detail'] {
152+
:host([has-detail-min-size][orientation='vertical']:not([overlay]):not([stack])) [part='detail'] {
153153
min-height: var(--_detail-min-size);
154154
}
155+
156+
/* Stack mode */
157+
:host([stack]) [part='master'] {
158+
max-height: 100%;
159+
}
160+
161+
:host([stack]) [part='detail'] {
162+
inset: 0;
163+
}
164+
165+
[part='master']::before {
166+
background-position-y: var(--_stack-threshold);
167+
}
155168
`;
156169
}
157170

@@ -253,6 +266,18 @@ class MasterDetailLayout extends ResizeMixin(ElementMixin(ThemableMixin(PolylitM
253266
reflectToAttribute: true,
254267
sync: true,
255268
},
269+
270+
/**
271+
* The threshold (in CSS length units) at which the layout switches to
272+
* the "stack" mode, making detail area fully cover the master area.
273+
*
274+
* @attr {string} stack-threshold
275+
*/
276+
stackThreshold: {
277+
type: String,
278+
observer: '__stackThresholdChanged',
279+
sync: true,
280+
},
256281
};
257282
}
258283

@@ -320,6 +345,19 @@ class MasterDetailLayout extends ResizeMixin(ElementMixin(ThemableMixin(PolylitM
320345
}
321346
}
322347

348+
/** @private */
349+
__stackThresholdChanged(threshold, oldThreshold) {
350+
if (threshold || oldThreshold) {
351+
if (threshold) {
352+
this.$.master.style.setProperty('--_stack-threshold', threshold);
353+
} else {
354+
this.$.master.style.removeProperty('--_stack-threshold');
355+
}
356+
357+
this.__detectLayoutMode();
358+
}
359+
}
360+
323361
/** @private */
324362
__updateStyleProperty(prop, size, oldSize) {
325363
if (size) {
@@ -335,14 +373,28 @@ class MasterDetailLayout extends ResizeMixin(ElementMixin(ThemableMixin(PolylitM
335373
__detectLayoutMode() {
336374
if (!this.hasAttribute('has-detail')) {
337375
this.removeAttribute('overlay');
376+
this.removeAttribute('stack');
338377
return;
339378
}
340379

341380
if (this.forceOverlay) {
381+
this.removeAttribute('stack');
342382
this.setAttribute('overlay', '');
343383
return;
344384
}
345385

386+
if (this.stackThreshold != null) {
387+
this.removeAttribute('stack');
388+
389+
const threshold = this.__getStackThresholdInPixels();
390+
const size = this.orientation === 'vertical' ? this.offsetHeight : this.offsetWidth;
391+
if (size <= threshold) {
392+
this.removeAttribute('overlay');
393+
this.setAttribute('stack', '');
394+
return;
395+
}
396+
}
397+
346398
if (this.orientation === 'vertical') {
347399
this.__detectVerticalMode();
348400
} else {
@@ -394,6 +446,12 @@ class MasterDetailLayout extends ResizeMixin(ElementMixin(ThemableMixin(PolylitM
394446
this.setAttribute('overlay', '');
395447
}
396448
}
449+
450+
/** @private */
451+
__getStackThresholdInPixels() {
452+
const { backgroundPositionY } = getComputedStyle(this.$.master, '::before');
453+
return parseFloat(backgroundPositionY);
454+
}
397455
}
398456

399457
defineCustomElement(MasterDetailLayout);

packages/master-detail-layout/test/master-detail-layout.test.js

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -519,4 +519,167 @@ describe('vaadin-master-detail-layout', () => {
519519
});
520520
});
521521
});
522+
523+
describe('stack', () => {
524+
let width, height;
525+
526+
before(() => {
527+
width = window.innerWidth;
528+
height = window.innerHeight;
529+
});
530+
531+
afterEach(async () => {
532+
await setViewport({ width, height });
533+
});
534+
535+
describe('default', () => {
536+
it('should switch from overlay to the stack mode when the stack threshold is set', async () => {
537+
// Use the threshold at which the overlay mode is on by default.
538+
await setViewport({ width: 350, height });
539+
await nextResize(layout);
540+
541+
layout.stackThreshold = '400px';
542+
543+
expect(layout.hasAttribute('overlay')).to.be.false;
544+
expect(layout.hasAttribute('stack')).to.be.true;
545+
expect(getComputedStyle(detail).position).to.equal('absolute');
546+
expect(getComputedStyle(detail).inset).to.equal('0px');
547+
});
548+
549+
it('should clear the stack mode when the layout size is bigger than stack threshold', async () => {
550+
layout.stackThreshold = '400px';
551+
await nextRender();
552+
553+
await setViewport({ width: 350, height });
554+
await nextResize(layout);
555+
556+
await setViewport({ width: 450, height });
557+
await nextResize(layout);
558+
559+
expect(layout.hasAttribute('stack')).to.be.false;
560+
});
561+
562+
it('should not switch to the stack mode when forceOverlay is set to true', async () => {
563+
layout.forceOverlay = true;
564+
layout.stackThreshold = '500px';
565+
await nextRender();
566+
567+
await setViewport({ width: 450, height });
568+
await nextResize(layout);
569+
570+
expect(layout.hasAttribute('stack')).to.be.false;
571+
expect(layout.hasAttribute('overlay')).to.be.true;
572+
});
573+
574+
it('should not apply min-width to the detail area in the stack mode', async () => {
575+
layout.detailMinSize = '500px';
576+
layout.stackThreshold = '500px';
577+
await nextRender();
578+
579+
await setViewport({ width: 450, height });
580+
await nextResize(layout);
581+
582+
expect(layout.hasAttribute('stack')).to.be.true;
583+
expect(getComputedStyle(detail).width).to.equal('450px');
584+
});
585+
586+
it('should not apply width to the detail area in the stack mode', async () => {
587+
layout.detailSize = '500px';
588+
layout.stackThreshold = '500px';
589+
await nextRender();
590+
591+
await setViewport({ width: 450, height });
592+
await nextResize(layout);
593+
594+
expect(layout.hasAttribute('stack')).to.be.true;
595+
expect(getComputedStyle(detail).width).to.equal('450px');
596+
});
597+
598+
it('should update stack mode when adding and removing details', async () => {
599+
layout.stackThreshold = '500px';
600+
601+
// Start without details
602+
detailContent.remove();
603+
await nextRender();
604+
605+
// Shrink viewport
606+
await setViewport({ width: 450, height });
607+
await nextResize(layout);
608+
609+
expect(layout.hasAttribute('stack')).to.be.false;
610+
611+
// Add details
612+
layout.appendChild(detailContent);
613+
await nextRender();
614+
615+
expect(layout.hasAttribute('stack')).to.be.true;
616+
617+
// Remove details
618+
detailContent.remove();
619+
await nextRender();
620+
621+
expect(layout.hasAttribute('stack')).to.be.false;
622+
});
623+
});
624+
625+
describe('vertical orientation', () => {
626+
beforeEach(() => {
627+
layout.orientation = 'vertical';
628+
layout.style.maxHeight = '500px';
629+
layout.parentElement.style.height = '100%';
630+
});
631+
632+
it('should switch from overlay to the stack mode when the stack threshold is set', async () => {
633+
// Use the threshold at which the overlay mode is on by default.
634+
await setViewport({ width: 500, height: 400 });
635+
await nextResize(layout);
636+
637+
layout.stackThreshold = '400px';
638+
639+
expect(layout.hasAttribute('overlay')).to.be.false;
640+
expect(layout.hasAttribute('stack')).to.be.true;
641+
expect(getComputedStyle(detail).position).to.equal('absolute');
642+
expect(getComputedStyle(detail).inset).to.equal('0px');
643+
});
644+
645+
it('should use fixed position in the stack mode when viewport containment is used', async () => {
646+
layout.containment = 'viewport';
647+
648+
// Use the threshold at which the overlay mode is on by default.
649+
await setViewport({ width: 500, height: 400 });
650+
await nextResize(layout);
651+
652+
layout.stackThreshold = '400px';
653+
654+
expect(layout.hasAttribute('overlay')).to.be.false;
655+
expect(layout.hasAttribute('stack')).to.be.true;
656+
expect(getComputedStyle(detail).position).to.equal('fixed');
657+
expect(getComputedStyle(detail).inset).to.equal('0px');
658+
});
659+
660+
it('should not apply min-height to the detail area in the stack mode', async () => {
661+
layout.detailMinSize = '500px';
662+
layout.stackThreshold = '500px';
663+
await nextRender();
664+
665+
await setViewport({ width, height: 450 });
666+
await nextResize(layout);
667+
668+
expect(layout.hasAttribute('stack')).to.be.true;
669+
expect(getComputedStyle(detail).height).to.equal('450px');
670+
});
671+
672+
it('should not apply height to the detail area in the stack mode', async () => {
673+
layout.detailSize = '500px';
674+
layout.stackThreshold = '500px';
675+
await nextRender();
676+
677+
await setViewport({ width, height: 450 });
678+
await nextResize(layout);
679+
680+
expect(layout.hasAttribute('stack')).to.be.true;
681+
expect(getComputedStyle(detail).height).to.equal('450px');
682+
});
683+
});
684+
});
522685
});

packages/master-detail-layout/test/typings/master-detail-layout.types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,3 +17,4 @@ assertType<string | null | undefined>(layout.masterSize);
1717
assertType<string | null | undefined>(layout.masterMinSize);
1818
assertType<'horizontal' | 'vertical'>(layout.orientation);
1919
assertType<boolean>(layout.forceOverlay);
20+
assertType<string | null | undefined>(layout.stackThreshold);

0 commit comments

Comments
 (0)