From 6b839c7c2e85e206b23864a7dc0c597648183270 Mon Sep 17 00:00:00 2001 From: Sergey Vinogradov Date: Mon, 23 Mar 2026 18:17:30 +0400 Subject: [PATCH 01/23] feat: calc detail size automatically when not provided --- dev/master-detail-layout.html | 26 +++- ...vaadin-master-detail-layout-base-styles.js | 45 ++++--- .../src/vaadin-master-detail-layout.js | 111 +++++++++++++----- 3 files changed, 129 insertions(+), 53 deletions(-) diff --git a/dev/master-detail-layout.html b/dev/master-detail-layout.html index ed58879cabe..44d29a9e664 100644 --- a/dev/master-detail-layout.html +++ b/dev/master-detail-layout.html @@ -72,11 +72,13 @@ @@ -94,6 +96,7 @@ import '@vaadin/icon'; import '@vaadin/master-detail-layout'; import '@vaadin/tooltip'; + import '@vaadin/form-layout'; import '@vaadin/vaadin-lumo-styles/icons'; import { html, LitElement, render } from 'lit'; @@ -157,6 +160,10 @@ this._parentMdl._setDetail(null); } + recalculateDetailSize() { + this._mdl.recalculateDetailSize(); + } + replaceDetail() { window.mdlCount++; const detail = this.createDetail(); @@ -228,7 +235,13 @@

View ${window.mdlCount}

- + @@ -260,6 +273,7 @@

View ${window.mdlCount}

Open Static Detail Open Nested Test View + Recalculate Detail Size

${lorem}

diff --git a/packages/master-detail-layout/src/styles/vaadin-master-detail-layout-base-styles.js b/packages/master-detail-layout/src/styles/vaadin-master-detail-layout-base-styles.js index 4abcf3af3eb..e1223964b87 100644 --- a/packages/master-detail-layout/src/styles/vaadin-master-detail-layout-base-styles.js +++ b/packages/master-detail-layout/src/styles/vaadin-master-detail-layout-base-styles.js @@ -8,10 +8,8 @@ import { css } from 'lit'; export const masterDetailLayoutStyles = css` :host { - --_master-size: 30em; - --_detail-size: 15em; - --_master-column: var(--_master-size) 0; - --_detail-column: var(--_detail-size) 0; + --_master-size: min(10rem, 100%); + --_detail-size: var(--_detail-cached-size, min-content); --_transition-duration: 0s; --_transition-easing: cubic-bezier(0.78, 0, 0.22, 1); --_rtl-multiplier: 1; @@ -22,8 +20,11 @@ export const masterDetailLayoutStyles = css` height: 100%; position: relative; z-index: 0; - overflow: hidden; - grid-template-columns: [master-start] var(--_master-column) [detail-start] var(--_detail-column) [detail-end]; + overflow: clip; + grid-template-columns: + [master-start] var(--_master-size) var(--_master-extra, 0px) + [detail-start] var(--_detail-size) var(--_detail-extra, 0px) + [detail-end]; grid-template-rows: 100%; } @@ -39,7 +40,10 @@ export const masterDetailLayoutStyles = css` --_detail-offscreen: 0 30px; grid-template-columns: 100%; - grid-template-rows: [master-start] var(--_master-column) [detail-start] var(--_detail-column) [detail-end]; + grid-template-rows: + [master-start] var(--_master-size) var(--_master-extra, 0px) + [detail-start] var(--_detail-size) var(--_detail-extra, 0px) + [detail-end]; } :is(#master, #detail, #detail-placeholder, #outgoing) { @@ -86,18 +90,25 @@ export const masterDetailLayoutStyles = css` :host([expand='both']), :host([expand='master']) { - --_master-column: var(--_master-size) 1fr; + --_master-extra: 1fr; } - :host([keep-detail-column-offscreen]), - :host([has-detail-placeholder][overlay]), - :host(:not([has-detail-placeholder], [has-detail])) { - --_master-column: var(--_master-size) calc(100% - var(--_master-size)); + :host([expand='both'][has-detail]), + :host([expand='detail'][has-detail]) { + --_detail-extra: 1fr; } - :host([expand='both']), - :host([expand='detail']) { - --_detail-column: var(--_detail-size) 1fr; + :host([recalculating-detail-size][has-detail]) { + --_master-extra: 0px; + --_detail-extra: 0px; + } + + :host(:not([has-detail])), + :host([has-detail-placeholder][overlay]), + + :host([keep-detail-column-offscreen]), + :host([keep-detail-column-offscreen][recalculating-detail-size]) { + --_master-extra: calc(100% - var(--_master-size)); } :host([orientation='horizontal']) #detail-placeholder, @@ -150,13 +161,13 @@ export const masterDetailLayoutStyles = css` :host([has-detail][overlay]:not([orientation='vertical'])) :is(#detail, #outgoing) { inset-block: 0; inset-inline-end: 0; - width: var(--_overlay-size, var(--_detail-size, min-content)); + width: var(--_overlay-size, var(--_detail-size)); } :host([has-detail][overlay][orientation='vertical']) :is(#detail, #outgoing) { inset-inline: 0; inset-block-end: 0; - height: var(--_overlay-size, var(--_detail-size, min-content)); + height: var(--_overlay-size, var(--_detail-size)); } :host([has-detail][overlay][overlay-containment='viewport']) :is(#detail, #outgoing, #backdrop) { diff --git a/packages/master-detail-layout/src/vaadin-master-detail-layout.js b/packages/master-detail-layout/src/vaadin-master-detail-layout.js index f9c76a765d2..b453e7e09b0 100644 --- a/packages/master-detail-layout/src/vaadin-master-detail-layout.js +++ b/packages/master-detail-layout/src/vaadin-master-detail-layout.js @@ -20,6 +20,18 @@ function parseTrackSizes(gridTemplate) { .map(parseFloat); } +function detectOverflow(hostSize, trackSizes) { + const [masterSize, masterExtra, detailSize] = trackSizes; + + if (Math.floor(masterSize + masterExtra + detailSize) <= Math.floor(hostSize)) { + return false; + } + if (Math.floor(masterExtra) >= Math.floor(detailSize)) { + return false; + } + return true; +} + /** * `` is a web component for building UIs with a master * (or primary) area and a detail (or secondary) area that is displayed next to, or @@ -105,9 +117,14 @@ class MasterDetailLayout extends ElementMixin(ThemableMixin(PolylitMixin(LitElem return { /** * Size (in CSS length units) to be set on the detail area in - * the CSS grid layout. If there is not enough space to show + * the CSS grid layout. When there is not enough space to show * master and detail areas next to each other, the detail area - * is shown as an overlay. Defaults to 15em. + * is shown as an overlay. + *

+ * If not specified, the size is determined automatically by measuring + * the detail content in a `min-content` CSS grid column when it first + * becomes visible, and then caching the result. To recalculate the cached + * size, use the `recalculateDetailSize` method. * * @attr {string} detail-size */ @@ -200,6 +217,13 @@ class MasterDetailLayout extends ElementMixin(ThemableMixin(PolylitMixin(LitElem type: Boolean, sync: true, }, + + /** @private */ + __detailCachedSize: { + type: String, + observer: '__detailCachedSizeChanged', + sync: true, + }, }; } @@ -258,6 +282,7 @@ class MasterDetailLayout extends ElementMixin(ThemableMixin(PolylitMixin(LitElem /** @private */ __detailSizeChanged(size, oldSize) { this.__updateStyleProperty('detail-size', size, oldSize); + this.__detailCachedSize = null; } /** @private */ @@ -265,6 +290,11 @@ class MasterDetailLayout extends ElementMixin(ThemableMixin(PolylitMixin(LitElem this.__updateStyleProperty('overlay-size', size, oldSize); } + /** @private */ + __detailCachedSizeChanged(size, oldSize) { + this.__updateStyleProperty('detail-cached-size', size, oldSize); + } + /** @private */ __updateStyleProperty(prop, size, oldSize) { if (size) { @@ -297,9 +327,9 @@ class MasterDetailLayout extends ElementMixin(ThemableMixin(PolylitMixin(LitElem * @private */ __onResize() { - const state = this.__computeLayoutState(); + const state = this.__readLayoutState(); cancelAnimationFrame(this.__resizeRaf); - this.__resizeRaf = requestAnimationFrame(() => this.__applyLayoutState(state)); + this.__resizeRaf = requestAnimationFrame(() => this.__writeLayoutState(state)); } /** @@ -307,26 +337,55 @@ class MasterDetailLayout extends ElementMixin(ThemableMixin(PolylitMixin(LitElem * ResizeObserver callback where layout is already computed (no forced reflow). * @private */ - __computeLayoutState() { - const detailContent = this.querySelector(':scope > [slot="detail"]'); + __readLayoutState() { + const isVertical = this.orientation === 'vertical'; + + const detailContent = this.querySelector('[slot="detail"]'); const detailPlaceholder = this.querySelector(':scope > [slot="detail-placeholder"]'); const hadDetail = this.hasAttribute('has-detail'); const hasDetail = detailContent != null && detailContent.checkVisibility(); const hasDetailPlaceholder = !!detailPlaceholder; - const hasOverflow = (hasDetail || hasDetailPlaceholder) && this.__checkOverflow(); + const computedStyle = getComputedStyle(this); + const hostSizeProp = isVertical ? 'height' : 'width'; + const hostSize = parseFloat(computedStyle[hostSizeProp]); + + const trackSizesProp = isVertical ? 'gridTemplateRows' : 'gridTemplateColumns'; + const trackSizes = parseTrackSizes(computedStyle[trackSizesProp]); + + const hasOverflow = (hasDetail || hasDetailPlaceholder) && detectOverflow(hostSize, trackSizes); const focusTarget = !hadDetail && hasDetail && hasOverflow ? getFocusableElements(detailContent)[0] : null; - return { hadDetail, hasDetail, hasDetailPlaceholder, hasOverflow, focusTarget }; + + return { + hadDetail, + hasDetail, + hasOverflow, + focusTarget, + hostSize, + trackSizes, + }; } /** * Applies layout state to DOM attributes. Pure writes, no reads. * @private */ - __applyLayoutState({ hadDetail, hasDetail, hasDetailPlaceholder, hasOverflow, focusTarget }) { - // Set keep-detail-column-offscreen when detail first appears with overlay - // to prevent master width from jumping. + __writeLayoutState({ hadDetail, hasDetail, hasDetailPlaceholder, hasOverflow, focusTarget, trackSizes }) { + const [_masterSize, _masterExtra, detailSize] = trackSizes; + + // If no detailSize is explicitily set, cache the intrinsic size (min-content) of + // the slotted detail content to use as a fallback for the detail column size + // while the detail content is rendered in an overlay. + if (!this.detailSize && !this.__detailCachedSize && hasDetail && detailSize > 0) { + this.__detailCachedSize = `${Math.ceil(detailSize)}px`; + } else { + this.__detailCachedSize = null; + } + + // Force the detail column offscreen when it first appears and overflow + // is already detected. This prevents unnecessary master column shrinking, + // as the detail content is rendered in an overlay anyway. if (!hadDetail && hasDetail && hasOverflow) { this.setAttribute('keep-detail-column-offscreen', ''); } else if (!hasDetail || !hasOverflow) { @@ -346,23 +405,15 @@ class MasterDetailLayout extends ElementMixin(ThemableMixin(PolylitMixin(LitElem } } - /** @private */ - __checkOverflow() { - const isVertical = this.orientation === 'vertical'; - const computedStyle = getComputedStyle(this); + recalculateDetailSize() { + this.__detailCachedSize = null; + this.removeAttribute('overflow'); + this.toggleAttribute('recalculating-detail-size', true); - const hostSize = parseFloat(computedStyle[isVertical ? 'height' : 'width']); - const [masterSize, masterExtra, detailSize] = parseTrackSizes( - computedStyle[isVertical ? 'gridTemplateRows' : 'gridTemplateColumns'], - ); + const { focusTarget, ...state } = this.__readLayoutState(); + this.__writeLayoutState(state); - if (Math.floor(masterSize + masterExtra + detailSize) <= Math.floor(hostSize)) { - return false; - } - if (Math.floor(masterExtra) >= Math.floor(detailSize)) { - return false; - } - return true; + this.toggleAttribute('recalculating-detail-size', false); } /** @private */ @@ -414,8 +465,8 @@ class MasterDetailLayout extends ElementMixin(ThemableMixin(PolylitMixin(LitElem if (skipTransition || this.noAnimation) { updateSlot(); queueMicrotask(() => { - const state = this.__computeLayoutState(); - this.__applyLayoutState(state); + const state = this.__readLayoutState(); + this.__writeLayoutState(state); }); return Promise.resolve(); } @@ -561,8 +612,8 @@ class MasterDetailLayout extends ElementMixin(ThemableMixin(PolylitMixin(LitElem * @protected */ _finishTransition() { - const state = this.__computeLayoutState(); - this.__applyLayoutState(state); + const state = this.__readLayoutState(); + this.__writeLayoutState(state); } /** From efbccbe7f2c6547faa596c5a1021c66b009f608b Mon Sep 17 00:00:00 2001 From: Sergey Vinogradov Date: Wed, 25 Mar 2026 12:26:04 +0400 Subject: [PATCH 02/23] wip --- dev/master-detail-layout.html | 2 +- .../styles/vaadin-master-detail-layout-base-styles.js | 7 +++---- .../src/vaadin-master-detail-layout.js | 11 +++++++---- 3 files changed, 11 insertions(+), 9 deletions(-) diff --git a/dev/master-detail-layout.html b/dev/master-detail-layout.html index 44d29a9e664..621b3a0f4fc 100644 --- a/dev/master-detail-layout.html +++ b/dev/master-detail-layout.html @@ -72,7 +72,7 @@