diff --git a/dev/master-detail-layout.html b/dev/master-detail-layout.html index ed58879cabe..c19451b7371 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); } + recalculateLayout() { + this._mdl.recalculateLayout(); + } + 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 Layout

${lorem}

diff --git a/dev/mdl-use-cases/nav-section-list-detail.html b/dev/mdl-use-cases/nav-section-list-detail.html index b2686afb4c6..9927aef1fa4 100644 --- a/dev/mdl-use-cases/nav-section-list-detail.html +++ b/dev/mdl-use-cases/nav-section-list-detail.html @@ -91,7 +91,6 @@ @@ -108,7 +107,6 @@

MDL

@@ -126,7 +124,6 @@

Manage

diff --git a/packages/master-detail-layout/ARCHITECTURE.md b/packages/master-detail-layout/ARCHITECTURE.md index 5c631d808b2..f1a60ab99bb 100644 --- a/packages/master-detail-layout/ARCHITECTURE.md +++ b/packages/master-detail-layout/ARCHITECTURE.md @@ -10,9 +10,11 @@ The grid uses **4 column tracks** with named lines. Each logical column (master, CSS custom properties: -- `--_master-column: var(--_master-size) 0` — default: fixed size + 0 extra -- `--_detail-column: var(--_detail-size) 0` — default: fixed size + 0 extra -- `--_master-size` / `--_detail-size` — default to `30em` / `15em` in `:host`; overridden from JS when `masterSize`/`detailSize` properties are set +- `--_master-size` — defaults to `30rem`; overridden from JS when `masterSize` is set +- `--_master-extra` — defaults to `0px`; set to `1fr` by expand modes +- `--_detail-size` — resolves to `var(--_detail-cached-size)`, which defaults to `min-content` (auto-sized) or is set from JS when `detailSize` is provided +- `--_detail-extra` — defaults to `0px`; set to `1fr` by expand modes +- `--_detail-cached-size` — the cached intrinsic size of the detail content (see Auto Detail Size) Parts use **named grid lines** for placement: @@ -23,12 +25,12 @@ Parts use **named grid lines** for placement: The `expand` attribute controls which extra track(s) become `1fr`: -| `expand` | `--_master-column` | `--_detail-column` | -| -------- | ------------------ | ------------------ | -| (none) | `size 0` | `size 0` | -| `both` | `size 1fr` | `size 1fr` | -| `master` | `size 1fr` | `size 0` | -| `detail` | `size 0` | `size 1fr` | +| `expand` | `--_master-extra` | `--_detail-extra` | +| -------- | ----------------- | ----------------- | +| (none) | `0px` | `0px` | +| `both` | `1fr` | `1fr` | +| `master` | `1fr` | `0px` | +| `detail` | `0px` | `1fr` | ### Vertical orientation @@ -36,11 +38,11 @@ In vertical mode, `grid-template-rows` replaces `grid-template-columns` using th ### Default sizes -`--_master-size` and `--_detail-size` default to `30em` and `15em` respectively in `:host`. When `masterSize`/`detailSize` properties are set, JS overrides these CSS custom properties. When cleared, JS removes the inline style and the defaults apply again. +`--_master-size` defaults to `30rem`. `--_detail-size` resolves to `var(--_detail-cached-size)`, which defaults to `min-content` for auto-sizing. When `masterSize`/`detailSize` properties are set, JS overrides these CSS custom properties. When cleared, JS removes the inline style and the defaults apply again. ## Overflow Detection -`__checkOverflow()` reads the first 3 of the 4 computed track sizes: `[masterSize, masterExtra, detailSize]`. The 4th (detail extra) is 0 in overflow scenarios. +The `detectOverflow()` helper reads the first 3 of the 4 computed track sizes: `[masterSize, masterExtra, detailSize]`. The 4th (detail extra) is 0 in overflow scenarios. **No overflow** when either: @@ -53,8 +55,8 @@ The `>=` (not `>`) is intentional: when `keep-detail-column-offscreen` or `:not( Layout detection is split into two methods to avoid forced reflows: -- **`__readLayoutState()`** — pure reads: `checkVisibility()`, `getComputedStyle()`, `getFocusableElements()`. Called in the ResizeObserver callback where layout is already computed — no forced reflow. -- **`__writeLayoutState(state)`** — pure writes: toggles `has-detail`, `overlay`, `keep-detail-column-offscreen`; calls `requestUpdate()` for ARIA; focuses detail. No DOM/style reads. +- **`__readLayoutState()`** — pure reads: `checkVisibility()`, `getComputedStyle()`, `getFocusableElements()`. Called in the ResizeObserver callback where layout is already computed — no forced reflow. Also returns `hostSize` and `trackSizes` for overflow detection and auto-size caching. +- **`__writeLayoutState(state)`** — pure writes: toggles `has-detail`, `overlay`, `keep-detail-column-offscreen`; caches intrinsic detail size; calls `requestUpdate()` for ARIA; focuses detail. No DOM/style reads. ### ResizeObserver @@ -62,12 +64,30 @@ Layout detection is split into two methods to avoid forced reflows: - ResizeObserver callback: calls `__readLayoutState()` (read), cancels any pending rAF via `cancelAnimationFrame`, then defers `__writeLayoutState()` (write) via `requestAnimationFrame`. Cancelling ensures the write phase always uses the latest state when multiple callbacks fire per frame. - **Property observers** (`masterSize`/`detailSize`) only update CSS custom properties — ResizeObserver picks up the resulting size changes automatically +### Stale rAF safety + +The only code paths that modify layout attributes (`has-detail`, `overlay`, etc.) are `__writeLayoutState` (called by the rAF itself or by `recalculateLayout`) and `recalculateLayout` (which always starts with `cancelAnimationFrame`). A pending rAF that isn't cancelled simply re-applies the same state it read — an idempotent no-op. The `cancelAnimationFrame` in `recalculateLayout` prevents stale rAFs from overwriting freshly computed state after transitions or property changes. + +## Auto Detail Size + +When `detailSize` is not explicitly set, the detail column size is determined automatically from the detail content's intrinsic size using `min-content`. + +### How it works + +The detail column uses `--_detail-size: var(--_detail-cached-size)` with `--_detail-cached-size` defaulting to `min-content`. On first render the browser sizes the column to the content's intrinsic width. The write phase then caches that measurement as a fixed pixel value in `--_detail-cached-size`, keeping the column stable across overlay transitions and re-renders. The cache is cleared when the detail is removed so the next detail is measured fresh. + +### `recalculateLayout()` + +Re-measures the intrinsic size by clearing the cache and temporarily placing the detail back into a `min-content` grid column (via the `recalculating-detail-size` attribute, which also disables `1fr` expansion to avoid distorting the measurement). Propagates to ancestor auto-sized layouts so nested layouts re-measure correctly. + +Called when `masterSize`/`detailSize` change after initial render and after detail transitions. The property observers skip the call on the initial set (`oldSize != null` guard) to avoid a redundant synchronous recalculation — the ResizeObserver handles the first measurement. + ## Overlay Modes When `overlay` AND `has-detail` are both set, the detail becomes an overlay: - `position: absolute; grid-column: none` removes detail from grid flow -- Backdrop becomes visible +- Backdrop becomes visible (`opacity: 1`, `pointer-events: auto`) - `overlaySize` (CSS custom property `--_overlay-size`) controls overlay dimensions; falls back to `--_detail-size` - `overlayContainment` (`layout`/`viewport`) controls positioning: `absolute` vs `fixed` - ARIA: `role="dialog"` on detail, `inert` on master (layout containment), `aria-modal` (viewport containment) @@ -85,11 +105,15 @@ Setting `overlaySize` to `100%` makes the detail cover the full layout (replaces The `detail-placeholder` slot shows content in the detail area when no detail is open (e.g. "Select an item"). It occupies the same grid tracks as the detail and receives the same border styling. -Visible only when a placeholder element is slotted, no detail is present, and there is no overlay: +Visible only when a placeholder element is slotted, no detail is present, and there is no overlay. Uses `visibility: hidden/visible` (not `display: none/block`) so it always participates in grid sizing: ```css +#detail-placeholder { + visibility: hidden; +} + :host([has-detail-placeholder]:not([has-detail], [overlay])) #detail-placeholder { - display: block; + visibility: visible; } ``` @@ -105,7 +129,7 @@ When neither detail nor placeholder is present, master's extra track is set to ` :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)); + --_master-extra: calc(100% - var(--_master-size)); } ``` @@ -113,7 +137,7 @@ Set when detail first appears with overlay, cleared when detail is removed or ov ## Detail Animations -Detail panel transitions use the Web Animations API (`element.animate()`) on `translate` and `opacity`. This works inside shadow roots (unlike the View Transitions API). +Detail panel transitions use the Web Animations API (`element.animate()`) on `translate` and `opacity`. This works inside shadow roots (unlike the View Transitions API). The host uses `overflow: clip` (not `overflow: hidden`) to clip offscreen content without creating a scroll container. ### CSS custom properties @@ -123,28 +147,41 @@ Animation parameters are driven by CSS custom properties, read once per transiti - `--_transition-duration` — defaults to `0s`, enabled via `@media (prefers-reduced-motion: no-preference)`: 200ms for split mode, 300ms for overlay mode. Replace transitions in split mode use 0ms (no slide, just instant swap). - `--_transition-easing` — cubic-bezier easing -CSS handles resting states: `translate: var(--_detail-offscreen)` on `#detail` by default, overridden to `translate: none` by `:host([has-detail])`. RTL is supported via `--_rtl-multiplier`. +CSS handles resting states via `translate` and `visibility` on `#detail`: offscreen and hidden by default, on-screen and visible when `has-detail` is set. RTL is supported via `--_rtl-multiplier`. ### Transition types - **Add**: DOM is updated first (new element inserted, `has-detail` set), then the detail slides in from off-screen. In split mode, also fades from opacity 0 → 1. -- **Remove**: the detail slides out to off-screen first (in split mode, also fades to opacity 0), then the DOM is updated (element removed, `has-detail` cleared) on `animation.finished` +- **Remove**: the detail slides out to off-screen first (in split mode, also fades to opacity 0), then the DOM is updated (element removed, `has-detail` cleared) on `animation.finished`. The `fill: 'forwards'` on the animation keeps the detail offscreen between animation end and the deferred layout recalculation (see below). - **Replace** (overlay): old content is reassigned to `slot="detail-outgoing"` (stays in light DOM so styles continue to apply), then old slides out while new slides in simultaneously - **Replace** (split): old content moves to outgoing slot. The outgoing slides out with fade on top (`z-index: 1`), revealing the incoming at full opacity underneath. The `noAnimation` property (reflected as `no-animation` attribute) skips all animations. Animations are also disabled when `--_transition-duration` resolves to `0s`. +### Backdrop fade + +The backdrop uses `opacity: 0/1` + `pointer-events: none/auto` (not `display: none/block`) so it can be animated. A linear opacity fade runs in parallel with the detail slide for overlay add/remove transitions. During replace, the backdrop stays visible (no fade). + ### Transition flow 1. **Capture interrupted state** — read the detail panel's current `translate` and `opacity` via `getComputedStyle()` _before_ cancelling any in-progress animation (see "Smooth interruption" below) 2. **Cancel previous** — cancel in-progress animations, clean up state, resolve the pending promise 3. **Snapshot outgoing** — reassign old content to the outgoing slot (replace only) -4. **DOM update** — run the update callback, apply layout state (add/replace only; remove defers this to step 6) -5. **Animate** — create Web Animations on `translate` and `opacity` -6. **Finish** — on `animation.finished`, clean up the `transition` attribute and resolve the promise. For remove, the deferred DOM update runs here +4. **DOM update** — run the update callback (add/replace only; remove defers this to step 6). The callback calls `_finishTransition()` which defers layout recalculation to a microtask via `queueMicrotask(() => recalculateLayout())`. +5. **Animate** — create Web Animations on `translate` and `opacity` with `fill: 'forwards'` +6. **Finish** — on `animation.finished`, run the deferred callback (remove only), then `__endTransition()` cancels animations (removing the fill effect) and resolves the promise. The microtask from `_finishTransition` runs before the next paint, applying the correct post-transition layout state. A version counter guards step 6: if a newer transition has started since step 5, the stale finish callback is ignored. +### `fill: 'forwards'` and async layout recalculation + +All animations use `fill: 'forwards'` to keep the final keyframe applied after the animation finishes. This bridges the gap between animation end and the microtask-deferred `recalculateLayout()`: + +- Without fill: animation ends → CSS resting state takes over (e.g., `translate: none` from `has-detail`) → visual flash +- With fill: animation ends → fill holds the final position → `__endTransition()` cancels the animation (removes fill) → but the deferred `recalculateLayout` microtask has already run, clearing `has-detail` so CSS resting state is also offscreen → no flash + +The microtask deferral in `_finishTransition` is intentional: it ensures `recalculateLayout()` reads clean computed styles without `fill: 'forwards'` interference (since `__endTransition` cancels animations before the microtask runs). + ### Smooth interruption `animation.cancel()` removes the animation effect and the element reverts to its CSS resting state — causing a visual jump. To avoid this, the current `translate` and `opacity` values are read via `getComputedStyle()` _before_ cancelling. These captured mid-flight values become the starting keyframe of the new animation, so the panel changes direction and opacity smoothly from where it actually is. 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..ef41148e541 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,12 @@ 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: 30rem; + --_master-extra: 0px; + --_detail-size: var(--_detail-cached-size); + --_detail-extra: 0px; + --_detail-cached-size: min-content; + --_transition-duration: 0s; --_transition-easing: cubic-bezier(0.78, 0, 0.22, 1); --_rtl-multiplier: 1; @@ -22,12 +24,16 @@ 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) + [detail-start] var(--_detail-size) var(--_detail-extra) + [detail-end]; grid-template-rows: 100%; } - :host([hidden]) { + :host([hidden]), + ::slotted([hidden]) { display: none !important; } @@ -39,7 +45,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) + [detail-start] var(--_detail-size) var(--_detail-extra) + [detail-end]; } :is(#master, #detail, #detail-placeholder, #outgoing) { @@ -47,11 +56,11 @@ export const masterDetailLayoutStyles = css` } #detail-placeholder { - display: none; + visibility: hidden; } :host([has-detail-placeholder]:not([has-detail], [overlay])) #detail-placeholder { - display: block; + visibility: visible; } #master { @@ -86,28 +95,32 @@ export const masterDetailLayoutStyles = css` :host([expand='both']), :host([expand='master']) { - --_master-column: var(--_master-size) 1fr; + --_master-extra: 1fr; + } + + :host([expand='both']:is([has-detail], [has-detail-placeholder])), + :host([expand='detail']:is([has-detail], [has-detail-placeholder])) { + --_detail-extra: 1fr; + } + + :host([recalculating-detail-size]:is([has-detail], [has-detail-placeholder])) { + --_detail-extra: 0px; } :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']), - :host([expand='detail']) { - --_detail-column: var(--_detail-size) 1fr; + --_master-extra: calc(100% - var(--_master-size)); } :host([orientation='horizontal']) #detail-placeholder, - :host([orientation='horizontal'][has-detail]:not([overlay])) #detail { + :host([orientation='horizontal']:not([overlay])) #detail { border-inline-start: var(--vaadin-master-detail-layout-border-width, 1px) solid var(--vaadin-master-detail-layout-border-color, var(--vaadin-border-color-secondary)); } :host([orientation='vertical']) #detail-placeholder, - :host([orientation='vertical'][has-detail]:not([overlay])) #detail { + :host([orientation='vertical']:not([overlay])) #detail { border-top: var(--vaadin-master-detail-layout-border-width, 1px) solid var(--vaadin-master-detail-layout-border-color, var(--vaadin-border-color-secondary)); } @@ -115,10 +128,12 @@ export const masterDetailLayoutStyles = css` /* Detail transition: off-screen by default, on-screen when has-detail */ #detail { translate: var(--_detail-offscreen); + visibility: hidden; } :host([has-detail]) #detail { translate: none; + visibility: visible; } #outgoing:not([hidden]) { @@ -150,13 +165,15 @@ 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)); + max-width: 100%; } :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)); + max-height: 100%; } :host([has-detail][overlay][overlay-containment='viewport']) :is(#detail, #outgoing, #backdrop) { diff --git a/packages/master-detail-layout/src/vaadin-master-detail-layout.d.ts b/packages/master-detail-layout/src/vaadin-master-detail-layout.d.ts index 25b81d77889..687970d1517 100644 --- a/packages/master-detail-layout/src/vaadin-master-detail-layout.d.ts +++ b/packages/master-detail-layout/src/vaadin-master-detail-layout.d.ts @@ -84,9 +84,15 @@ export interface MasterDetailLayoutEventMap extends HTMLElementEventMap, MasterD declare class MasterDetailLayout extends ThemableMixin(ElementMixin(HTMLElement)) { /** * 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 resulting intrinsic size. To + * recalculate the cached intrinsic size, use the `recalculateLayout` + * method. * * @attr {string} detail-size */ @@ -143,6 +149,21 @@ declare class MasterDetailLayout extends ThemableMixin(ElementMixin(HTMLElement) */ noAnimation: boolean; + /** + * When `detailSize` is not explicitly set, re-measures the cached intrinsic size of + * the detail content by placing it in a min-content CSS grid column, then repeats + * this process for ancestor master-detail layouts without an explicit `detailSize`, + * if any, so that their detail areas also adapt. + * + * Call this method after changing the detail content in a way that affects its intrinsic + * size — for example, when opening a detail in a nested master-detail layout that was + * not previously visible. + * + * NOTE: This method can be expensive in large layouts as it triggers consecutive + * synchronous DOM reads and writes. + */ + recalculateLayout(): void; + addEventListener( type: K, listener: (this: MasterDetailLayout, ev: MasterDetailLayoutEventMap[K]) => void, 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 143b9980dab..291af506635 100644 --- a/packages/master-detail-layout/src/vaadin-master-detail-layout.js +++ b/packages/master-detail-layout/src/vaadin-master-detail-layout.js @@ -6,6 +6,7 @@ import { html, LitElement, nothing } from 'lit'; import { getFocusableElements } from '@vaadin/a11y-base/src/focus-utils.js'; import { defineCustomElement } from '@vaadin/component-base/src/define.js'; +import { getClosestElement } from '@vaadin/component-base/src/dom-utils.js'; import { ElementMixin } from '@vaadin/component-base/src/element-mixin.js'; import { PolylitMixin } from '@vaadin/component-base/src/polylit-mixin.js'; import { ThemableMixin } from '@vaadin/vaadin-themable-mixin/vaadin-themable-mixin.js'; @@ -20,6 +21,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 +118,15 @@ 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 resulting intrinsic size. To + * recalculate the cached intrinsic size, use the `recalculateLayout` + * method. * * @attr {string} detail-size */ @@ -155,6 +174,7 @@ class MasterDetailLayout extends ElementMixin(ThemableMixin(PolylitMixin(LitElem value: 'horizontal', reflectToAttribute: true, sync: true, + observer: '__orientationChanged', }, /** @@ -200,6 +220,13 @@ class MasterDetailLayout extends ElementMixin(ThemableMixin(PolylitMixin(LitElem type: Boolean, sync: true, }, + + /** @private */ + __detailCachedSize: { + type: String, + observer: '__detailCachedSizeChanged', + sync: true, + }, }; } @@ -253,11 +280,26 @@ class MasterDetailLayout extends ElementMixin(ThemableMixin(PolylitMixin(LitElem /** @private */ __masterSizeChanged(size, oldSize) { this.__updateStyleProperty('master-size', size, oldSize); + + if (oldSize != null) { + this.recalculateLayout(); + } } /** @private */ __detailSizeChanged(size, oldSize) { this.__updateStyleProperty('detail-size', size, oldSize); + + if (oldSize != null) { + this.recalculateLayout(); + } + } + + /** @private */ + __orientationChanged(_orientation, oldOrientation) { + if (oldOrientation != null) { + this.recalculateLayout(); + } } /** @private */ @@ -265,6 +307,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) { @@ -308,25 +355,55 @@ class MasterDetailLayout extends ElementMixin(ThemableMixin(PolylitMixin(LitElem * @private */ __readLayoutState() { + const isVertical = this.orientation === 'vertical'; + const detailContent = this.querySelector(':scope > [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, + hasDetailPlaceholder, + hasOverflow, + focusTarget, + hostSize, + trackSizes, + }; } /** * Applies layout state to DOM attributes. Pure writes, no reads. * @private */ - __writeLayoutState({ 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 ((hasDetail || hasDetailPlaceholder) && this.__isDetailAutoSized && detailSize > 0) { + this.__detailCachedSize = 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 +423,59 @@ class MasterDetailLayout extends ElementMixin(ThemableMixin(PolylitMixin(LitElem } } - /** @private */ - __checkOverflow() { - const isVertical = this.orientation === 'vertical'; - const computedStyle = getComputedStyle(this); + /** + * When `detailSize` is not explicitly set, re-measures the cached intrinsic size of + * the detail content by placing it in a min-content CSS grid column, then repeats + * this process for ancestor master-detail layouts without an explicit `detailSize`, + * if any, so that their detail areas also adapt. + * + * Call this method after changing the detail content in a way that affects its intrinsic + * size — for example, when opening a detail in a nested master-detail layout that was + * not previously visible. + * + * NOTE: This method can be expensive in large layouts as it triggers consecutive + * synchronous DOM reads and writes. + */ + recalculateLayout() { + // Cancel any pending ResizeObserver rAF to prevent it from potentially + // overriding the layout state with stale measurements. + cancelAnimationFrame(this.__resizeRaf); - const hostSize = parseFloat(computedStyle[isVertical ? 'height' : 'width']); - const [masterSize, masterExtra, detailSize] = parseTrackSizes( - computedStyle[isVertical ? 'gridTemplateRows' : 'gridTemplateColumns'], - ); + const invalidatedLayouts = [...this.__ancestorLayouts.filter((layout) => layout.__isDetailAutoSized), this]; - if (Math.floor(masterSize + masterExtra + detailSize) <= Math.floor(hostSize)) { - return false; - } - if (Math.floor(masterExtra) >= Math.floor(detailSize)) { - return false; - } - return true; + // Write + invalidatedLayouts.forEach((layout) => { + layout.__detailCachedSize = null; + + if (layout.__isDetailAutoSized) { + layout.removeAttribute('overlay'); + layout.toggleAttribute('recalculating-detail-size', true); + } + }); + + // Read/Write + invalidatedLayouts.forEach((layout) => { + const state = layout.__readLayoutState(); + layout.__writeLayoutState(state); + }); + + // Write + invalidatedLayouts.forEach((layout) => { + if (layout.__isDetailAutoSized) { + layout.toggleAttribute('recalculating-detail-size', false); + } + }); + } + + /** @private */ + get __isDetailAutoSized() { + return this.detailSize == null; + } + + /** @private */ + get __ancestorLayouts() { + const parent = getClosestElement(this.constructor.is, this.parentNode); + return parent ? [...parent.__ancestorLayouts, parent] : []; } /** @private */ @@ -413,10 +526,7 @@ class MasterDetailLayout extends ElementMixin(ThemableMixin(PolylitMixin(LitElem if (skipTransition || this.noAnimation) { updateSlot(); - queueMicrotask(() => { - const state = this.__readLayoutState(); - this.__writeLayoutState(state); - }); + queueMicrotask(() => this.recalculateLayout()); return Promise.resolve(); } @@ -561,8 +671,7 @@ class MasterDetailLayout extends ElementMixin(ThemableMixin(PolylitMixin(LitElem * @protected */ _finishTransition() { - const state = this.__readLayoutState(); - this.__writeLayoutState(state); + queueMicrotask(() => this.recalculateLayout()); } /** @@ -670,10 +779,6 @@ class MasterDetailLayout extends ElementMixin(ThemableMixin(PolylitMixin(LitElem } this.removeAttribute('transition'); this.__clearOutgoing(); - // Cancel any pending ResizeObserver rAF that captured stale state - // during the animation — _finishTransition already applied the - // correct post-transition state synchronously. - cancelAnimationFrame(this.__resizeRaf); if (this.__transitionResolve) { this.__transitionResolve(); this.__transitionResolve = null; diff --git a/packages/master-detail-layout/test/detail-auto-size.test.js b/packages/master-detail-layout/test/detail-auto-size.test.js new file mode 100644 index 00000000000..02fdad97e99 --- /dev/null +++ b/packages/master-detail-layout/test/detail-auto-size.test.js @@ -0,0 +1,203 @@ +import { expect } from '@vaadin/chai-plugins'; +import { defineCE, fixtureSync } from '@vaadin/testing-helpers'; +import sinon from 'sinon'; +import '../src/vaadin-master-detail-layout.js'; +import { css, html, LitElement } from 'lit'; +import { onceResized } from './helpers.js'; + +window.Vaadin ||= {}; +window.Vaadin.featureFlags ||= {}; +window.Vaadin.featureFlags.masterDetailLayoutComponent = true; + +describe('detail auto size', () => { + let layout; + + function getCachedSize(layout) { + return layout.style.getPropertyValue('--_detail-cached-size'); + } + + describe('basic', () => { + let spy; + + beforeEach(async () => { + layout = fixtureSync(` + +

Master
+
Detail
+
+ `); + await onceResized(layout); + spy = sinon.spy(layout, 'recalculateLayout'); + }); + + it('should not be called when masterSize and detailSize are provided initially', async () => { + const newLayout = fixtureSync(` + +
Master
+
Detail
+
+ `); + const newSpy = sinon.spy(newLayout, 'recalculateLayout'); + await onceResized(newLayout); + expect(newSpy).to.not.be.called; + }); + + it('should be called when masterSize is changed after initial render', () => { + layout.masterSize = '200px'; + layout.masterSize = '300px'; + expect(spy).to.be.calledOnce; + }); + + it('should be called when detailSize is changed after initial render', () => { + layout.detailSize = '200px'; + layout.detailSize = '300px'; + expect(spy).to.be.calledOnce; + }); + + it('should not be called when orientation is provided initially', async () => { + const newLayout = fixtureSync(` + +
Master
+
Detail
+
+ `); + const newSpy = sinon.spy(newLayout, 'recalculateLayout'); + await onceResized(newLayout); + expect(newSpy).to.not.be.called; + }); + + it('should be called when orientation is changed after initial render', () => { + layout.orientation = 'vertical'; + expect(spy).to.be.calledOnce; + }); + + it('should not throw when called on a disconnected element', () => { + layout.parentElement.removeChild(layout); + expect(() => layout.recalculateLayout()).to.not.throw(); + }); + }); + + describe('Lit element detail', () => { + const detailElementTag = defineCE( + class extends LitElement { + static get styles() { + return css` + :host { + display: block; + } + + div { + width: 200px; + } + `; + } + + render() { + return html`
Detail
`; + } + }, + ); + + beforeEach(async () => { + layout = fixtureSync(` + +
Master
+
+ `); + await onceResized(layout); + }); + + it('should measure correct detail size for a Lit element set via _setDetail', async () => { + await layout._setDetail(document.createElement(detailElementTag)); + await onceResized(layout); + expect(getCachedSize(layout)).to.equal('201px'); + }); + + it('should measure correct detail size for a Lit element set via _setDetail without transition', async () => { + await layout._setDetail(document.createElement(detailElementTag), true); + await onceResized(layout); + expect(getCachedSize(layout)).to.equal('201px'); + }); + }); + + describe('nested layouts', () => { + let outer, middle, inner; + + const shadowElement = defineCE( + class extends HTMLElement { + constructor() { + super(); + this.attachShadow({ mode: 'open' }); + this.shadowRoot.appendChild(this.querySelector('template').content.cloneNode(true)); + } + }, + ); + + beforeEach(async () => { + outer = fixtureSync(` + +
Outer Master
+ + +
Middle Master
+ + <${shadowElement} slot="detail"> + + +
+
+ `); + middle = outer.querySelector('vaadin-master-detail-layout'); + inner = middle.querySelector('[slot="detail"]').shadowRoot.querySelector('vaadin-master-detail-layout'); + + await onceResized(outer); + await onceResized(middle); + await onceResized(inner); + }); + + it('should cache detail intrinsic size plus border at each level', () => { + // Inner: 100px detail content + 1px border = 101px + expect(getCachedSize(inner)).to.equal('101px'); + // Middle: inner layout min-content (100px master + 101px detail) + 1px border = 202px + expect(getCachedSize(middle)).to.equal('202px'); + // Outer: middle layout min-content (100px master + 202px detail) + 1px border = 303px + expect(getCachedSize(outer)).to.equal('303px'); + }); + + it('should not cache detail size when detailSize is explicitly set', async () => { + outer.detailSize = '300px'; + await onceResized(outer); + expect(getCachedSize(outer)).to.equal(''); + }); + + describe('recalculateLayout', () => { + it('should update cached sizes on ancestors after detail content changes', () => { + inner.querySelector('[slot="detail"]').style.width = '200px'; + inner.recalculateLayout(); + expect(getCachedSize(inner)).to.equal('201px'); + expect(getCachedSize(middle)).to.equal('302px'); + expect(getCachedSize(outer)).to.equal('403px'); + }); + + it('should toggle overlay on ancestors when detail content outgrows or fits available space', () => { + // Grow inner detail so outer needs 100px + 1103px = 1203px > 1200px + inner.querySelector('[slot="detail"]').style.width = '1000px'; + inner.recalculateLayout(); + expect(middle.hasAttribute('overlay')).to.be.true; + expect(outer.hasAttribute('overlay')).to.be.true; + + // Shrink back so outer needs 100px + 303px = 403px < 1200px + inner.querySelector('[slot="detail"]').style.width = '100px'; + inner.recalculateLayout(); + expect(middle.hasAttribute('overlay')).to.be.false; + expect(outer.hasAttribute('overlay')).to.be.false; + }); + }); + }); +}); diff --git a/packages/master-detail-layout/test/dom/__snapshots__/master-detail-layout.test.snap.js b/packages/master-detail-layout/test/dom/__snapshots__/master-detail-layout.test.snap.js index e3aba9d58c0..afe44f146a8 100644 --- a/packages/master-detail-layout/test/dom/__snapshots__/master-detail-layout.test.snap.js +++ b/packages/master-detail-layout/test/dom/__snapshots__/master-detail-layout.test.snap.js @@ -96,13 +96,12 @@ snapshots["vaadin-master-detail-layout shadow default"] = snapshots["vaadin-master-detail-layout detail default"] = `
Master content @@ -116,14 +115,12 @@ snapshots["vaadin-master-detail-layout detail default"] = snapshots["vaadin-master-detail-layout detail overflow"] = `
Master content @@ -137,12 +134,11 @@ snapshots["vaadin-master-detail-layout detail overflow"] = snapshots["vaadin-master-detail-layout detail hidden"] = `
Master content @@ -159,12 +155,11 @@ snapshots["vaadin-master-detail-layout detail hidden"] = snapshots["vaadin-master-detail-layout detail removed"] = `
Master content @@ -175,13 +170,12 @@ snapshots["vaadin-master-detail-layout detail removed"] = snapshots["vaadin-master-detail-layout detail placeholder default"] = `
Master content @@ -195,14 +189,12 @@ snapshots["vaadin-master-detail-layout detail placeholder default"] = snapshots["vaadin-master-detail-layout detail placeholder overflow"] = `
Master content @@ -216,12 +208,11 @@ snapshots["vaadin-master-detail-layout detail placeholder overflow"] = snapshots["vaadin-master-detail-layout detail placeholder removed"] = `
Master content diff --git a/packages/master-detail-layout/test/dom/master-detail-layout.test.js b/packages/master-detail-layout/test/dom/master-detail-layout.test.js index c2fde1204e6..a029429cd54 100644 --- a/packages/master-detail-layout/test/dom/master-detail-layout.test.js +++ b/packages/master-detail-layout/test/dom/master-detail-layout.test.js @@ -58,7 +58,7 @@ describe('vaadin-master-detail-layout', () => { describe('detail', () => { beforeEach(async () => { layout = fixtureSync(` - +
Master content
Detail content
@@ -92,7 +92,7 @@ describe('vaadin-master-detail-layout', () => { describe('detail placeholder', () => { beforeEach(async () => { layout = fixtureSync(` - +
Master content
Detail placeholder content
diff --git a/packages/master-detail-layout/test/visual/base/master-detail-layout.test.js b/packages/master-detail-layout/test/visual/base/master-detail-layout.test.js index 0f475d591d3..a906bdbae28 100644 --- a/packages/master-detail-layout/test/visual/base/master-detail-layout.test.js +++ b/packages/master-detail-layout/test/visual/base/master-detail-layout.test.js @@ -147,4 +147,79 @@ describe('master-detail-layout', () => { await visualDiff(div, `detail-placeholder-expand-detail`); }); }); + + describe('nested layouts', () => { + let outer, inner; + + function openDetail(layout) { + // Set detail content and skip animation + return layout._setDetail(layout.querySelector(':scope > [slot="detail-hidden"]'), true); + } + + beforeEach(async () => { + outer = fixtureSync( + ` + +
+ Outer Master +
+ +
+ Inner Master +
+
+ Inner Detail +
+
+
+ `, + div, + ); + inner = outer.querySelector('vaadin-master-detail-layout'); + await onceResized(div); + }); + + it('default', async () => { + await visualDiff(div, `nested-layouts`); + }); + + describe('outer opened', () => { + beforeEach(async () => { + await openDetail(outer); + }); + + it('default', async () => { + await visualDiff(div, `nested-layouts-outer-opened`); + }); + + it('overflow', async () => { + div.style.width = '400px'; + await onceResized(div); + await visualDiff(div, `nested-layouts-outer-opened-overflow`); + }); + }); + + describe('inner opened', () => { + beforeEach(async () => { + await openDetail(outer); + await openDetail(inner); + }); + + it('default', async () => { + await visualDiff(div, `nested-layouts-inner-opened`); + }); + + it('outer overflow', async () => { + div.style.width = '400px'; + await onceResized(div); + await visualDiff(div, `nested-layouts-inner-opened-outer-overflow`); + }); + + it('inner overflow', async () => { + div.style.width = '200px'; + await onceResized(div); + await visualDiff(div, `nested-layouts-inner-opened-inner-overflow`); + }); + }); + }); }); diff --git a/packages/master-detail-layout/test/visual/base/screenshots/master-detail-layout/baseline/nested-layouts-inner-opened-inner-overflow.png b/packages/master-detail-layout/test/visual/base/screenshots/master-detail-layout/baseline/nested-layouts-inner-opened-inner-overflow.png new file mode 100644 index 00000000000..1b089eac378 Binary files /dev/null and b/packages/master-detail-layout/test/visual/base/screenshots/master-detail-layout/baseline/nested-layouts-inner-opened-inner-overflow.png differ diff --git a/packages/master-detail-layout/test/visual/base/screenshots/master-detail-layout/baseline/nested-layouts-inner-opened-outer-overflow.png b/packages/master-detail-layout/test/visual/base/screenshots/master-detail-layout/baseline/nested-layouts-inner-opened-outer-overflow.png new file mode 100644 index 00000000000..e68c6a14979 Binary files /dev/null and b/packages/master-detail-layout/test/visual/base/screenshots/master-detail-layout/baseline/nested-layouts-inner-opened-outer-overflow.png differ diff --git a/packages/master-detail-layout/test/visual/base/screenshots/master-detail-layout/baseline/nested-layouts-inner-opened.png b/packages/master-detail-layout/test/visual/base/screenshots/master-detail-layout/baseline/nested-layouts-inner-opened.png new file mode 100644 index 00000000000..1ebd196b6e1 Binary files /dev/null and b/packages/master-detail-layout/test/visual/base/screenshots/master-detail-layout/baseline/nested-layouts-inner-opened.png differ diff --git a/packages/master-detail-layout/test/visual/base/screenshots/master-detail-layout/baseline/nested-layouts-outer-opened-overflow.png b/packages/master-detail-layout/test/visual/base/screenshots/master-detail-layout/baseline/nested-layouts-outer-opened-overflow.png new file mode 100644 index 00000000000..9e0bd3a0013 Binary files /dev/null and b/packages/master-detail-layout/test/visual/base/screenshots/master-detail-layout/baseline/nested-layouts-outer-opened-overflow.png differ diff --git a/packages/master-detail-layout/test/visual/base/screenshots/master-detail-layout/baseline/nested-layouts-outer-opened.png b/packages/master-detail-layout/test/visual/base/screenshots/master-detail-layout/baseline/nested-layouts-outer-opened.png new file mode 100644 index 00000000000..6ab0ea05d3d Binary files /dev/null and b/packages/master-detail-layout/test/visual/base/screenshots/master-detail-layout/baseline/nested-layouts-outer-opened.png differ diff --git a/packages/master-detail-layout/test/visual/base/screenshots/master-detail-layout/baseline/nested-layouts.png b/packages/master-detail-layout/test/visual/base/screenshots/master-detail-layout/baseline/nested-layouts.png new file mode 100644 index 00000000000..c1bff91c7be Binary files /dev/null and b/packages/master-detail-layout/test/visual/base/screenshots/master-detail-layout/baseline/nested-layouts.png differ