Skip to content
Merged
Show file tree
Hide file tree
Changes from 21 commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
6b839c7
feat: calc detail size automatically when not provided
vursen Mar 23, 2026
efbccbe
wip
vursen Mar 25, 2026
5741efe
wip
vursen Mar 27, 2026
f8f9754
chore: add JSDoc for recalculateLayout to d.ts
vursen Mar 27, 2026
48de10d
add tests
vursen Mar 27, 2026
d296847
add more visual tests
vursen Mar 27, 2026
13568f1
add tests for recalculateLayout
vursen Mar 27, 2026
e832299
minimize diff
vursen Mar 27, 2026
a3e8b39
update ARCHITECTURE.md
vursen Mar 27, 2026
4c48578
improve test coverage, address edge cases
vursen Mar 27, 2026
c2d16f2
cancel pending resize observer rafs before sync layout recalculation
vursen Mar 30, 2026
cd95c1a
update detailSize JSDoc in d.ts to match JS implementation
vursen Mar 30, 2026
c09812e
fix crash in recalculateLayout when element is disconnected
vursen Mar 30, 2026
0e2b4c4
move disconnected element test to detail-auto-size.test.js
vursen Mar 30, 2026
b4a9d76
improve test coverage, update screenshots
vursen Mar 30, 2026
6984280
address review comments
vursen Mar 30, 2026
2844d39
defer recalculateLayout in _finishTransition to fix Lit element detai…
vursen Mar 31, 2026
2b8cfd2
Merge remote-tracking branch 'origin/main' into mdl/detail-auto-size
vursen Mar 31, 2026
9b626d4
update snapshot tests
vursen Mar 31, 2026
68333ae
Merge branch 'main' into mdl/detail-auto-size
vursen Mar 31, 2026
c58f704
call recalculateLayout on orientation change
vursen Mar 31, 2026
d639687
docs: fix stale and missing content in ARCHITECTURE.md
web-padawan Mar 31, 2026
1413dd3
fix rebase error
vursen Mar 31, 2026
ee1a042
use cloneNode(true) with template
vursen Mar 31, 2026
6ded042
test: revert whitespace change in snapshots
web-padawan Mar 31, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 20 additions & 6 deletions dev/master-detail-layout.html
Original file line number Diff line number Diff line change
Expand Up @@ -72,11 +72,13 @@

<template id="static-detail">
<div class="static-detail">
<vaadin-button>Close</vaadin-button><br />
<vaadin-text-field label="First Name"></vaadin-text-field>
<vaadin-text-field label="Last Name"></vaadin-text-field>
<vaadin-text-field label="Email"></vaadin-text-field>
<vaadin-select label="Country"></vaadin-select>
<vaadin-form-layout auto-responsive max-columns="3" auto-rows>
<vaadin-text-field label="First Name"></vaadin-text-field>
<vaadin-text-field label="Last Name"></vaadin-text-field>
<vaadin-text-field label="Email"></vaadin-text-field>
<vaadin-select label="Country"></vaadin-select>
</vaadin-form-layout>
<vaadin-button>Close</vaadin-button>
</div>
</template>

Expand All @@ -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';

Expand Down Expand Up @@ -157,6 +160,10 @@
this._parentMdl._setDetail(null);
}

recalculateLayout() {
this._mdl.recalculateLayout();
}

replaceDetail() {
window.mdlCount++;
const detail = this.createDetail();
Expand Down Expand Up @@ -228,7 +235,13 @@ <h3>View ${window.mdlCount}</h3>
<vaadin-radio-button value="layout" label="Layout"></vaadin-radio-button>
<vaadin-radio-button value="viewport" label="Viewport"></vaadin-radio-button>
</vaadin-radio-group>
<vaadin-radio-group @change=${this._configChange} class="expand" label="Expand" theme="vertical" value="both">
<vaadin-radio-group
@change=${this._configChange}
class="expand"
label="Expand"
theme="vertical"
value="master"
>
<vaadin-radio-button value="both" label="Both"></vaadin-radio-button>
<vaadin-radio-button value="master" label="Master"></vaadin-radio-button>
<vaadin-radio-button value="detail" label="Detail"></vaadin-radio-button>
Expand Down Expand Up @@ -260,6 +273,7 @@ <h3>View ${window.mdlCount}</h3>
<vaadin-checkbox @change=${this._togglePlaceholder} label="Detail Placeholder"></vaadin-checkbox>
<vaadin-button @click=${this.openStaticDetail}>Open Static Detail</vaadin-button>
<vaadin-button @click=${this.openDetail}>Open Nested Test View</vaadin-button>
<vaadin-button @click=${this.recalculateLayout}>Recalculate Layout</vaadin-button>
<p>${lorem}</p>
</div>
</vaadin-master-detail-layout>
Expand Down
3 changes: 0 additions & 3 deletions dev/mdl-use-cases/nav-section-list-detail.html
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,6 @@
<vaadin-master-detail-layout
id="appLayout"
master-size="180px"
detail-size="30rem"
expand="detail"
overlay-size="100%"
>
Expand All @@ -108,7 +107,6 @@ <h4>MDL</h4>
<vaadin-master-detail-layout
id="sectionLayout"
master-size="180px"
detail-size="30rem"
expand="detail"
overlay-size="100%"
>
Expand All @@ -126,7 +124,6 @@ <h4>Manage</h4>
<vaadin-master-detail-layout
id="listLayout"
master-size="300px"
detail-size="25rem"
expand="detail"
overlay-size="100%"
>
Expand Down
40 changes: 28 additions & 12 deletions packages/master-detail-layout/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand All @@ -23,20 +25,20 @@ 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

In vertical mode, `grid-template-rows` replaces `grid-template-columns` using the same named lines and variables. Parts switch from `grid-column` to `grid-row` placement.

### 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

Expand All @@ -53,15 +55,29 @@ 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

- **Observes**: host + shadow DOM parts (`master`, `detail`) + direct slotted children (`:scope >` prevents observing nested descendants)
- 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

## 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:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
}

Expand All @@ -39,19 +45,22 @@ 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) {
box-sizing: border-box;
}

#detail-placeholder {
display: none;
visibility: hidden;
}

:host([has-detail-placeholder]:not([has-detail], [overlay])) #detail-placeholder {
display: block;
visibility: visible;
}

#master {
Expand Down Expand Up @@ -86,39 +95,45 @@ 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));
}

/* 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]) {
Expand Down Expand Up @@ -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) {
Expand Down
25 changes: 23 additions & 2 deletions packages/master-detail-layout/src/vaadin-master-detail-layout.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
* <p>
* 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
*/
Expand Down Expand Up @@ -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<K extends keyof MasterDetailLayoutEventMap>(
type: K,
listener: (this: MasterDetailLayout, ev: MasterDetailLayoutEventMap[K]) => void,
Expand Down
Loading
Loading