Skip to content

Commit e8269d6

Browse files
vursenclaudeweb-padawan
authored
feat: calculate detail size automatically when not explicity set (#11403)
Previously, the detail area always used a fixed default size (15em) unless `detailSize` was explicitly set. This meant developers had to manually specify a size for every use case to avoid content being either too tight or wasting space. Now, when `detailSize` is not specified, the component automatically measures the intrinsic size of the detail content by placing it in a min-content CSS grid column when it first appears, caches the result, and uses it as the column size afterwards. This gives a good default without requiring any configuration. Setting `detailSize` explicitly still works as before and takes precedence. A new public `recalculateLayout()` method is provided for cases where the detail content changes dynamically (e.g., opening a nested master-detail layout) and the cached size needs to be re-measured. Because this method triggers synchronous DOM reads and writes that force layout recalculation, it can be expensive, so you should call it with caution and avoid calling it too frequently. The only place it is called automatically is the `_setDetail` protected method used by Flow to support variable view sizes. --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Co-authored-by: web-padawan <iamkulykov@gmail.com>
1 parent e1db911 commit e8269d6

16 files changed

+565
-105
lines changed

dev/master-detail-layout.html

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -72,11 +72,13 @@
7272

7373
<template id="static-detail">
7474
<div class="static-detail">
75-
<vaadin-button>Close</vaadin-button><br />
76-
<vaadin-text-field label="First Name"></vaadin-text-field>
77-
<vaadin-text-field label="Last Name"></vaadin-text-field>
78-
<vaadin-text-field label="Email"></vaadin-text-field>
79-
<vaadin-select label="Country"></vaadin-select>
75+
<vaadin-form-layout auto-responsive max-columns="3" auto-rows>
76+
<vaadin-text-field label="First Name"></vaadin-text-field>
77+
<vaadin-text-field label="Last Name"></vaadin-text-field>
78+
<vaadin-text-field label="Email"></vaadin-text-field>
79+
<vaadin-select label="Country"></vaadin-select>
80+
</vaadin-form-layout>
81+
<vaadin-button>Close</vaadin-button>
8082
</div>
8183
</template>
8284

@@ -94,6 +96,7 @@
9496
import '@vaadin/icon';
9597
import '@vaadin/master-detail-layout';
9698
import '@vaadin/tooltip';
99+
import '@vaadin/form-layout';
97100
import '@vaadin/vaadin-lumo-styles/icons';
98101
import { html, LitElement, render } from 'lit';
99102

@@ -157,6 +160,10 @@
157160
this._parentMdl._setDetail(null);
158161
}
159162

163+
recalculateLayout() {
164+
this._mdl.recalculateLayout();
165+
}
166+
160167
replaceDetail() {
161168
window.mdlCount++;
162169
const detail = this.createDetail();
@@ -228,7 +235,13 @@ <h3>View ${window.mdlCount}</h3>
228235
<vaadin-radio-button value="layout" label="Layout"></vaadin-radio-button>
229236
<vaadin-radio-button value="viewport" label="Viewport"></vaadin-radio-button>
230237
</vaadin-radio-group>
231-
<vaadin-radio-group @change=${this._configChange} class="expand" label="Expand" theme="vertical" value="both">
238+
<vaadin-radio-group
239+
@change=${this._configChange}
240+
class="expand"
241+
label="Expand"
242+
theme="vertical"
243+
value="master"
244+
>
232245
<vaadin-radio-button value="both" label="Both"></vaadin-radio-button>
233246
<vaadin-radio-button value="master" label="Master"></vaadin-radio-button>
234247
<vaadin-radio-button value="detail" label="Detail"></vaadin-radio-button>
@@ -260,6 +273,7 @@ <h3>View ${window.mdlCount}</h3>
260273
<vaadin-checkbox @change=${this._togglePlaceholder} label="Detail Placeholder"></vaadin-checkbox>
261274
<vaadin-button @click=${this.openStaticDetail}>Open Static Detail</vaadin-button>
262275
<vaadin-button @click=${this.openDetail}>Open Nested Test View</vaadin-button>
276+
<vaadin-button @click=${this.recalculateLayout}>Recalculate Layout</vaadin-button>
263277
<p>${lorem}</p>
264278
</div>
265279
</vaadin-master-detail-layout>

dev/mdl-use-cases/nav-section-list-detail.html

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,6 @@
9191
<vaadin-master-detail-layout
9292
id="appLayout"
9393
master-size="180px"
94-
detail-size="30rem"
9594
expand="detail"
9695
overlay-size="100%"
9796
>
@@ -108,7 +107,6 @@ <h4>MDL</h4>
108107
<vaadin-master-detail-layout
109108
id="sectionLayout"
110109
master-size="180px"
111-
detail-size="30rem"
112110
expand="detail"
113111
overlay-size="100%"
114112
>
@@ -126,7 +124,6 @@ <h4>Manage</h4>
126124
<vaadin-master-detail-layout
127125
id="listLayout"
128126
master-size="300px"
129-
detail-size="25rem"
130127
expand="detail"
131128
overlay-size="100%"
132129
>

packages/master-detail-layout/ARCHITECTURE.md

Lines changed: 60 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,11 @@ The grid uses **4 column tracks** with named lines. Each logical column (master,
1010

1111
CSS custom properties:
1212

13-
- `--_master-column: var(--_master-size) 0` — default: fixed size + 0 extra
14-
- `--_detail-column: var(--_detail-size) 0` — default: fixed size + 0 extra
15-
- `--_master-size` / `--_detail-size` — default to `30em` / `15em` in `:host`; overridden from JS when `masterSize`/`detailSize` properties are set
13+
- `--_master-size` — defaults to `30rem`; overridden from JS when `masterSize` is set
14+
- `--_master-extra` — defaults to `0px`; set to `1fr` by expand modes
15+
- `--_detail-size` — resolves to `var(--_detail-cached-size)`, which defaults to `min-content` (auto-sized) or is set from JS when `detailSize` is provided
16+
- `--_detail-extra` — defaults to `0px`; set to `1fr` by expand modes
17+
- `--_detail-cached-size` — the cached intrinsic size of the detail content (see Auto Detail Size)
1618

1719
Parts use **named grid lines** for placement:
1820

@@ -23,24 +25,24 @@ Parts use **named grid lines** for placement:
2325

2426
The `expand` attribute controls which extra track(s) become `1fr`:
2527

26-
| `expand` | `--_master-column` | `--_detail-column` |
27-
| -------- | ------------------ | ------------------ |
28-
| (none) | `size 0` | `size 0` |
29-
| `both` | `size 1fr` | `size 1fr` |
30-
| `master` | `size 1fr` | `size 0` |
31-
| `detail` | `size 0` | `size 1fr` |
28+
| `expand` | `--_master-extra` | `--_detail-extra` |
29+
| -------- | ----------------- | ----------------- |
30+
| (none) | `0px` | `0px` |
31+
| `both` | `1fr` | `1fr` |
32+
| `master` | `1fr` | `0px` |
33+
| `detail` | `0px` | `1fr` |
3234

3335
### Vertical orientation
3436

3537
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.
3638

3739
### Default sizes
3840

39-
`--_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.
41+
`--_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.
4042

4143
## Overflow Detection
4244

43-
`__checkOverflow()` reads the first 3 of the 4 computed track sizes: `[masterSize, masterExtra, detailSize]`. The 4th (detail extra) is 0 in overflow scenarios.
45+
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.
4446

4547
**No overflow** when either:
4648

@@ -53,21 +55,39 @@ The `>=` (not `>`) is intentional: when `keep-detail-column-offscreen` or `:not(
5355

5456
Layout detection is split into two methods to avoid forced reflows:
5557

56-
- **`__readLayoutState()`** — pure reads: `checkVisibility()`, `getComputedStyle()`, `getFocusableElements()`. Called in the ResizeObserver callback where layout is already computed — no forced reflow.
57-
- **`__writeLayoutState(state)`** — pure writes: toggles `has-detail`, `overlay`, `keep-detail-column-offscreen`; calls `requestUpdate()` for ARIA; focuses detail. No DOM/style reads.
58+
- **`__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.
59+
- **`__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.
5860

5961
### ResizeObserver
6062

6163
- **Observes**: host + shadow DOM parts (`master`, `detail`) + direct slotted children (`:scope >` prevents observing nested descendants)
6264
- 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.
6365
- **Property observers** (`masterSize`/`detailSize`) only update CSS custom properties — ResizeObserver picks up the resulting size changes automatically
6466

67+
### Stale rAF safety
68+
69+
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.
70+
71+
## Auto Detail Size
72+
73+
When `detailSize` is not explicitly set, the detail column size is determined automatically from the detail content's intrinsic size using `min-content`.
74+
75+
### How it works
76+
77+
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.
78+
79+
### `recalculateLayout()`
80+
81+
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.
82+
83+
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.
84+
6585
## Overlay Modes
6686

6787
When `overlay` AND `has-detail` are both set, the detail becomes an overlay:
6888

6989
- `position: absolute; grid-column: none` removes detail from grid flow
70-
- Backdrop becomes visible
90+
- Backdrop becomes visible (`opacity: 1`, `pointer-events: auto`)
7191
- `overlaySize` (CSS custom property `--_overlay-size`) controls overlay dimensions; falls back to `--_detail-size`
7292
- `overlayContainment` (`layout`/`viewport`) controls positioning: `absolute` vs `fixed`
7393
- 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
85105

86106
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.
87107

88-
Visible only when a placeholder element is slotted, no detail is present, and there is no overlay:
108+
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:
89109

90110
```css
111+
#detail-placeholder {
112+
visibility: hidden;
113+
}
114+
91115
:host([has-detail-placeholder]:not([has-detail], [overlay])) #detail-placeholder {
92-
display: block;
116+
visibility: visible;
93117
}
94118
```
95119

@@ -105,15 +129,15 @@ When neither detail nor placeholder is present, master's extra track is set to `
105129
:host([keep-detail-column-offscreen]),
106130
:host([has-detail-placeholder][overlay]),
107131
:host(:not([has-detail-placeholder], [has-detail])) {
108-
--_master-column: var(--_master-size) calc(100% - var(--_master-size));
132+
--_master-extra: calc(100% - var(--_master-size));
109133
}
110134
```
111135

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

114138
## Detail Animations
115139

116-
Detail panel transitions use the Web Animations API (`element.animate()`) on `translate` and `opacity`. This works inside shadow roots (unlike the View Transitions API).
140+
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.
117141

118142
### CSS custom properties
119143

@@ -123,28 +147,41 @@ Animation parameters are driven by CSS custom properties, read once per transiti
123147
- `--_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).
124148
- `--_transition-easing` — cubic-bezier easing
125149

126-
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`.
150+
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`.
127151

128152
### Transition types
129153

130154
- **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.
131-
- **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`
155+
- **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).
132156
- **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
133157
- **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.
134158

135159
The `noAnimation` property (reflected as `no-animation` attribute) skips all animations. Animations are also disabled when `--_transition-duration` resolves to `0s`.
136160

161+
### Backdrop fade
162+
163+
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).
164+
137165
### Transition flow
138166

139167
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)
140168
2. **Cancel previous** — cancel in-progress animations, clean up state, resolve the pending promise
141169
3. **Snapshot outgoing** — reassign old content to the outgoing slot (replace only)
142-
4. **DOM update** — run the update callback, apply layout state (add/replace only; remove defers this to step 6)
143-
5. **Animate** — create Web Animations on `translate` and `opacity`
144-
6. **Finish** — on `animation.finished`, clean up the `transition` attribute and resolve the promise. For remove, the deferred DOM update runs here
170+
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())`.
171+
5. **Animate** — create Web Animations on `translate` and `opacity` with `fill: 'forwards'`
172+
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.
145173

146174
A version counter guards step 6: if a newer transition has started since step 5, the stale finish callback is ignored.
147175

176+
### `fill: 'forwards'` and async layout recalculation
177+
178+
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()`:
179+
180+
- Without fill: animation ends → CSS resting state takes over (e.g., `translate: none` from `has-detail`) → visual flash
181+
- 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
182+
183+
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).
184+
148185
### Smooth interruption
149186

150187
`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.

0 commit comments

Comments
 (0)