Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
83 changes: 60 additions & 23 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,24 +25,24 @@ 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

`__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:

Expand All @@ -53,21 +55,39 @@ 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

### 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)
Expand All @@ -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;
}
```

Expand All @@ -105,15 +129,15 @@ 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));
}
```

Set when detail first appears with overlay, cleared when detail is removed or overlay resolves.

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

Expand All @@ -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.
Expand Down
Loading
Loading