Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
5efe928
refactor!: rewrite master-detail-layout with CSS grid
web-padawan Mar 16, 2026
29ffce9
refactor: optimize __onResize to read in ResizeObserver callback and …
vursen Mar 17, 2026
8aadb0a
fix: prevent false overflow detection due to sub-pixel rounding
vursen Mar 17, 2026
557e1ae
fix: scope resize observer to direct children only
vursen Mar 17, 2026
64eb03f
docs: update ARCHITECTURE.md for sub-pixel rounding and :scope scoping
web-padawan Mar 17, 2026
eb98ea7
fix: remove redundant has-detail condition from overlay styles
vursen Mar 17, 2026
6e6f7c3
fix: sync overlay state before view transition snapshot
web-padawan Mar 17, 2026
499f2ba
fix: CSS fallbacks for unset sizes and transition snapshot
web-padawan Mar 17, 2026
c211ee6
fix: view transitions and detail column collapse
web-padawan Mar 17, 2026
39ad639
test: update no-detail visual test screenshot
web-padawan Mar 17, 2026
c613b62
fix: cancel pending rAF in disconnectedCallback
web-padawan Mar 17, 2026
77c07e0
test: move import in DOM snapshot tests
web-padawan Mar 17, 2026
415ce24
chore: add prettier-ignore comments
web-padawan Mar 17, 2026
a953356
test: reduce diff in typings tests
web-padawan Mar 17, 2026
05c76eb
fix: use getComputedStyle for overflow detection to avoid sub-pixel r…
vursen Mar 17, 2026
7ea0ca4
fix: add explicit grid-template-rows and fix vertical orientation col…
vursen Mar 17, 2026
f7ab175
chore: add missing newline
web-padawan Mar 17, 2026
4a2800a
test: add has-detail visibility toggle test for master-detail-layout
vursen Mar 17, 2026
24f3c00
refactor: replace detailOverlayMode with overlaySize + overlayContain…
web-padawan Mar 17, 2026
90d579a
docs: align overlayContainment JSDoc with main branch containment pro…
web-padawan Mar 17, 2026
55a7780
chore: reorder detailSize before masterSize to match main branch
web-padawan Mar 17, 2026
634849e
chore: reorder orientation before overlayContainment to match main br…
web-padawan Mar 17, 2026
6d5c9ea
docs: replace column with area in size property JSDoc
web-padawan Mar 17, 2026
1337a85
chore: reorder :host([hidden]) in base styles to match main branch
web-padawan Mar 17, 2026
02409d5
docs: replace drawer mode with overlay mode in JSDoc and tests
web-padawan Mar 17, 2026
f5ede1c
test: improve ARIA test descriptions
web-padawan Mar 17, 2026
bcdf2a3
test: remove redundant overlaySize 100% ARIA test
web-padawan Mar 17, 2026
ecf2a7e
docs: improve masterSize and detailSize JSDoc to align with main branch
web-padawan Mar 17, 2026
9427608
test: restore Escape press test for split mode
web-padawan Mar 17, 2026
2ab3c59
test: simplify viewport containment tests using forEach
web-padawan Mar 17, 2026
b5bb62a
chore: format ARCHITECTURE.md
web-padawan Mar 17, 2026
e5b6df3
refactor: remove redundant [has-detail] attribute from selectors
vursen Mar 18, 2026
b5dd63f
refactor: use default CSS values for master and detail sizes
vursen Mar 18, 2026
6ff4cb8
docs: synchronize d.ts JSDoc with JS implementation
vursen Mar 18, 2026
f902614
docs: align masterSize/detailSize JSDoc in .d.ts with .js
web-padawan Mar 18, 2026
96e49ea
docs: restore noAnimation JSDoc from main branch
web-padawan Mar 18, 2026
c5e1689
docs: fix ARCHITECTURE.md to match current implementation
web-padawan Mar 18, 2026
5673a69
fix: expand master to fill layout when no detail with expand=detail
web-padawan Mar 18, 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
58 changes: 29 additions & 29 deletions dev/master-detail-layout.html
Original file line number Diff line number Diff line change
Expand Up @@ -197,14 +197,30 @@ <h3>View ${window.mdlCount}</h3>
</vaadin-radio-group>
<vaadin-radio-group
@change=${this._configChange}
class="containment"
label="Containment"
class="overlaySize"
label="Overlay Size"
theme="vertical"
value="auto"
>
<vaadin-radio-button value="auto" label="Auto"></vaadin-radio-button>
<vaadin-radio-button value="300px" label="300px"></vaadin-radio-button>
<vaadin-radio-button value="100%" label="100%"></vaadin-radio-button>
</vaadin-radio-group>
<vaadin-radio-group
@change=${this._configChange}
class="overlayContainment"
label="Overlay Containment"
theme="vertical"
value="layout"
>
<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-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>
</vaadin-radio-group>
<br />
<vaadin-radio-group
@change=${this._configChange}
Expand All @@ -217,21 +233,6 @@ <h3>View ${window.mdlCount}</h3>
<vaadin-radio-button value="250px" label="250px"></vaadin-radio-button>
<vaadin-radio-button value="30%" label="30%"></vaadin-radio-button>
</vaadin-radio-group>
<vaadin-radio-group
@change=${this._configChange}
class="masterMinSize"
label="Master Min Size"
theme="vertical"
value="none"
>
<vaadin-radio-button value="none" label="None"></vaadin-radio-button>
<vaadin-radio-button value="250px" label="250px"></vaadin-radio-button>
<vaadin-radio-button value="100%" label="100%"></vaadin-radio-button>
</vaadin-radio-group>
<br />
<vaadin-button @click=${this.openStaticDetail}>Open Static Detail</vaadin-button>
<vaadin-button @click=${this.openDetail}>Open Nested Test View</vaadin-button>
<br />
<vaadin-radio-group
@change=${this._configChange}
class="detailSize"
Expand All @@ -243,25 +244,24 @@ <h3>View ${window.mdlCount}</h3>
<vaadin-radio-button value="300px" label="300px"></vaadin-radio-button>
<vaadin-radio-button value="70%" label="70%"></vaadin-radio-button>
</vaadin-radio-group>
<vaadin-radio-group
@change=${this._configChange}
class="detailMinSize"
label="Detail Min Size"
theme="vertical"
value="none"
>
<vaadin-radio-button value="none" label="None"></vaadin-radio-button>
<vaadin-radio-button value="400px" label="400px"></vaadin-radio-button>
<vaadin-radio-button value="100%" label="100%"></vaadin-radio-button>
</vaadin-radio-group>
<br />
<vaadin-button @click=${this.openStaticDetail}>Open Static Detail</vaadin-button>
<vaadin-button @click=${this.openDetail}>Open Nested Test View</vaadin-button>
<p>${lorem}</p>
</div>
</vaadin-master-detail-layout>
`;
}

_configChange(e) {
this._mdl[e.currentTarget.className] = e.target.value.match(/auto|none/) ? null : e.target.value;
const prop = e.currentTarget.className;
const value = e.target.value;
// Size properties use null for "auto", other properties always have a value
if (prop === 'masterSize' || prop === 'detailSize' || prop === 'overlaySize') {
this._mdl[prop] = value === 'auto' ? null : value;
} else {
this._mdl[prop] = value;
}
}
}

Expand Down
10 changes: 5 additions & 5 deletions packages/aura/src/components/master-detail-layout.css
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ vaadin-master-detail-layout::part(detail) {
background: var(--aura-surface-color) padding-box;
}

vaadin-master-detail-layout[drawer]::part(detail) {
vaadin-master-detail-layout[overflow]::part(detail) {
--aura-surface-opacity: var(--aura-overlay-surface-opacity);
background: var(--aura-surface-color) padding-box;
-webkit-backdrop-filter: var(--aura-overlay-backdrop-filter);
Expand All @@ -14,19 +14,19 @@ vaadin-master-detail-layout[drawer]::part(detail) {
var(--aura-shadow-m);
}

vaadin-master-detail-layout[containment='viewport'][drawer]::part(detail) {
vaadin-master-detail-layout[overflow][overlay-containment='viewport']::part(detail) {
box-shadow: var(--aura-overlay-shadow);
}

/* TODO could be a built-in variant */
vaadin-master-detail-layout[theme~='inset-drawer'][drawer]::part(detail),
vaadin-master-detail-layout[containment='viewport'][drawer]::part(detail) {
vaadin-master-detail-layout[theme~='inset-drawer'][overflow]::part(detail),
vaadin-master-detail-layout[overflow][overlay-containment='viewport']::part(detail) {
margin: calc(var(--aura-app-layout-inset) / 2);
border-radius: var(--_app-layout-radius);
}

vaadin-master-detail-layout > vaadin-master-detail-layout,
vaadin-master-detail-layout:not([drawer])::part(detail) {
vaadin-master-detail-layout:not([overflow])::part(detail) {
border-start-end-radius: var(--_app-layout-radius);
border-end-end-radius: var(--_app-layout-radius);
}
Expand Down
121 changes: 121 additions & 0 deletions packages/master-detail-layout/ARCHITECTURE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
# Master-Detail Layout β€” CSS Grid Architecture

## 4-Column Grid System

The grid uses **4 column tracks** with named lines. Each logical column (master, detail) has a **size track** + an **extra track**:

```
[master-start] <master-size> <master-extra> [detail-start] <detail-size> <detail-extra> [detail-end]
```

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

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

- Master spans `master-start / detail-start` (size + extra)
- Detail spans `detail-start / detail-end` (size + extra)

### Expand modes

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

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

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

**No overflow** when either:

- `Math.floor(masterSize + masterExtra + detailSize) <= Math.floor(hostSize)` (tracks fit; flooring prevents false overflow from sub-pixel track sizes)
- `masterExtra >= detailSize` (master's extra space can absorb the detail)

The `>=` (not `>`) is intentional: when `preserve-master-width` or `:not([has-detail])` is active, CSS `calc(100% - masterSize)` inflates the master extra track. With this inflation, `masterExtra >= detailSize` is equivalent to `hostSize >= masterSize + detailSize` β€” the correct no-overflow check. Strict `>` would miss the boundary case where they're equal.

### Read/write separation

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

- **`__computeLayoutState()`** β€” pure reads: `checkVisibility()`, `getComputedStyle()`, `getFocusableElements()`. Called in the ResizeObserver callback where layout is already computed β€” no forced reflow.
- **`__applyLayoutState(state)`** β€” pure writes: toggles `has-detail`, `overflow`, `preserve-master-width`; 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 `__computeLayoutState()` (read), cancels any pending rAF via `cancelAnimationFrame`, then defers `__applyLayoutState()` (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

### View transitions

`_finishTransition()` uses `queueMicrotask` to call both `__computeLayoutState()` + `__applyLayoutState()` synchronously. The microtask runs before the Promise resolution propagates to `startViewTransition`, ensuring the "new" snapshot captures the correct overlay state (backdrop, absolute positioning). The `getComputedStyle` read in the microtask does cause a forced reflow, but this is unavoidable for correct transition snapshots.

## Overlay Modes

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

- `position: absolute; grid-column: none` removes detail from grid flow
- Backdrop becomes visible
- `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)

### Overlay positioning

| Orientation | Default | `overlayContainment='viewport'` |
| ----------- | ---------------------------------------------------- | ------------------------------- |
| Horizontal | `width: overlaySize/detailSize; inset-inline-end: 0` | `position: fixed` |
| Vertical | `height: overlaySize/detailSize; inset-block-end: 0` | `position: fixed` |

Setting `overlaySize` to `100%` makes the detail cover the full layout (replaces old "full" mode).

## preserve-master-width

Prevents the master from jumping when the detail overlay first appears.

**Problem**: When detail appears with overflow, the detail becomes absolute (out of grid flow). The master's `1fr` extra track expands since the detail's `1fr` is gone, causing a visual jump.

**Solution**: Replace `1fr` with `calc(100% - masterSize)` to keep the master at full host width. Same rule applies when `has-detail` is not set. For `expand='detail'`, the same compensation is applied when no detail is present to prevent the detail tracks from reserving space.

```css
:host([expand='both']:is(:not([has-detail]), [preserve-master-width])),
:host([expand='master']:is(:not([has-detail]), [preserve-master-width])),
:host([expand='detail']:not([has-detail])) {
--_master-column: var(--_master-size) calc(100% - var(--_master-size));
}
```

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

## View Transitions

Uses the CSS View Transitions API (`document.startViewTransition`):

- `_setDetail(element, skipTransition)` β€” adds/replaces/removes detail with animation
- `_startTransition(transitionType, updateCallback)` β€” starts a named transition
- `_finishTransition()` β€” calls `__computeLayoutState()` + `__applyLayoutState()` via `queueMicrotask` (see read/write separation above)
- `noAnimation` property disables transitions
- Styles injected via `SlotStylesMixin`

## Test Patterns

- **`onceResized(layout)`** (`test/helpers.js`): `nextResize()` + `nextRender()` β€” waits for ResizeObserver + rAF write phase in `__onResize()`
- **Split mode sizing**: measure part elements directly (not `gridTemplateColumns` which has 4 columns)
- **Vertical tests**: integrated into each test file under `describe('vertical')` suites
- **Feature flag**: `window.Vaadin.featureFlags.masterDetailLayoutComponent = true` required before import
Loading
Loading