You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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>
Copy file name to clipboardExpand all lines: packages/master-detail-layout/ARCHITECTURE.md
+60-23Lines changed: 60 additions & 23 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -10,9 +10,11 @@ The grid uses **4 column tracks** with named lines. Each logical column (master,
10
10
11
11
CSS custom properties:
12
12
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)
16
18
17
19
Parts use **named grid lines** for placement:
18
20
@@ -23,24 +25,24 @@ Parts use **named grid lines** for placement:
23
25
24
26
The `expand` attribute controls which extra track(s) become `1fr`:
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.
36
38
37
39
### Default sizes
38
40
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.
40
42
41
43
## Overflow Detection
42
44
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.
44
46
45
47
**No overflow** when either:
46
48
@@ -53,21 +55,39 @@ The `>=` (not `>`) is intentional: when `keep-detail-column-offscreen` or `:not(
53
55
54
56
Layout detection is split into two methods to avoid forced reflows:
55
57
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.
58
60
59
61
### ResizeObserver
60
62
61
63
-**Observes**: host + shadow DOM parts (`master`, `detail`) + direct slotted children (`:scope >` prevents observing nested descendants)
62
64
- 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.
63
65
-**Property observers** (`masterSize`/`detailSize`) only update CSS custom properties — ResizeObserver picks up the resulting size changes automatically
64
66
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
+
65
85
## Overlay Modes
66
86
67
87
When `overlay` AND `has-detail` are both set, the detail becomes an overlay:
68
88
69
89
-`position: absolute; grid-column: none` removes detail from grid flow
-`overlaySize` (CSS custom property `--_overlay-size`) controls overlay dimensions; falls back to `--_detail-size`
72
92
-`overlayContainment` (`layout`/`viewport`) controls positioning: `absolute` vs `fixed`
73
93
- 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
85
105
86
106
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.
87
107
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:
Set when detail first appears with overlay, cleared when detail is removed or overlay resolves.
113
137
114
138
## Detail Animations
115
139
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.
117
141
118
142
### CSS custom properties
119
143
@@ -123,28 +147,41 @@ Animation parameters are driven by CSS custom properties, read once per transiti
123
147
-`--_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).
124
148
-`--_transition-easing` — cubic-bezier easing
125
149
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`.
127
151
128
152
### Transition types
129
153
130
154
-**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).
132
156
-**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
133
157
-**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.
134
158
135
159
The `noAnimation` property (reflected as `no-animation` attribute) skips all animations. Animations are also disabled when `--_transition-duration` resolves to `0s`.
136
160
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
+
137
165
### Transition flow
138
166
139
167
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)
140
168
2.**Cancel previous** — cancel in-progress animations, clean up state, resolve the pending promise
141
169
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.
145
173
146
174
A version counter guards step 6: if a newer transition has started since step 5, the stale finish callback is ignored.
147
175
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
+
148
185
### Smooth interruption
149
186
150
187
`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