diff --git a/dev/master-detail-layout.html b/dev/master-detail-layout.html index 4d71b5cbdde..d3967d43a73 100644 --- a/dev/master-detail-layout.html +++ b/dev/master-detail-layout.html @@ -197,14 +197,30 @@

View ${window.mdlCount}

+ + + + + + + + + +
View ${window.mdlCount} - - - - - -
- Open Static Detail - Open Nested Test View -
View ${window.mdlCount} - - - - - +
+ Open Static Detail + Open Nested Test View

${lorem}

@@ -261,7 +254,14 @@

View ${window.mdlCount}

} _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; + } } } diff --git a/packages/aura/src/components/master-detail-layout.css b/packages/aura/src/components/master-detail-layout.css index 479a6343011..add17507125 100644 --- a/packages/aura/src/components/master-detail-layout.css +++ b/packages/aura/src/components/master-detail-layout.css @@ -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); @@ -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); } diff --git a/packages/master-detail-layout/ARCHITECTURE.md b/packages/master-detail-layout/ARCHITECTURE.md new file mode 100644 index 00000000000..5e93c6ea226 --- /dev/null +++ b/packages/master-detail-layout/ARCHITECTURE.md @@ -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] [detail-start] [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 diff --git a/packages/master-detail-layout/src/styles/vaadin-master-detail-layout-base-styles.js b/packages/master-detail-layout/src/styles/vaadin-master-detail-layout-base-styles.js index 84f0b04af63..3c1ed30c3a6 100644 --- a/packages/master-detail-layout/src/styles/vaadin-master-detail-layout-base-styles.js +++ b/packages/master-detail-layout/src/styles/vaadin-master-detail-layout-base-styles.js @@ -7,17 +7,20 @@ import '@vaadin/component-base/src/styles/style-props.js'; import { css } from 'lit'; export const masterDetailLayoutStyles = css` - /* Layout and positioning styles */ - :host { - display: flex; + --_master-size: 30em; + --_detail-size: 15em; + --_master-column: var(--_master-size) 0; + --_detail-column: var(--_detail-size) 0; + + display: grid; box-sizing: border-box; height: 100%; - max-width: 100%; - max-height: 100%; - position: relative; /* Keep the positioning context stable across all modes */ - z-index: 0; /* Create a new stacking context, don't let "layout contained" detail element stack outside it */ + position: relative; + z-index: 0; overflow: hidden; + grid-template-columns: [master-start] var(--_master-column) [detail-start] var(--_detail-column) [detail-end]; + grid-template-rows: 100%; } :host([hidden]) { @@ -25,171 +28,105 @@ export const masterDetailLayoutStyles = css` } :host([orientation='vertical']) { - flex-direction: column; - } - - [part='_detail-internal'] { - display: contents; - justify-content: end; - /* Disable pointer events for the detail wrapper to allow clicks to pass through to the backdrop */ - pointer-events: none; - } - - [part='detail'] { - /* Re-enable pointer events for the actual detail content */ - pointer-events: auto; - } - - :host([orientation='vertical']) [part='_detail-internal'] { - align-items: end; - } - - :host(:is([drawer], [stack])) [part='_detail-internal'], - :host(:is([drawer], [stack])[has-detail]) [part='backdrop'] { - display: flex; - position: absolute; - z-index: 1; - inset: 0; - overscroll-behavior: contain; - } - - :host(:not([has-detail])) [part='_detail-internal'], - [part='backdrop'] { - display: none; - } - - :host([orientation='horizontal'][drawer]) [part='detail'] { - margin-inline-start: 50px; - } - - :host([orientation='vertical'][drawer]) [part='detail'] { - margin-top: 50px; - } - - :host(:is([drawer], [stack])[containment='viewport']) :is([part='_detail-internal'], [part='backdrop']) { - position: fixed; + grid-template-columns: 100%; + grid-template-rows: [master-start] var(--_master-column) [detail-start] var(--_detail-column) [detail-end]; } - :host(:is([drawer], [stack])[containment='viewport']) [part='detail'] { - padding-top: var(--safe-area-inset-top); - padding-bottom: var(--safe-area-inset-bottom); - } - - :host([containment='viewport']:dir(ltr)) [part='detail'] { - padding-right: var(--safe-area-inset-right); - } - - :host([containment='viewport']:dir(rtl)) [part='detail'] { - padding-left: var(--safe-area-inset-left); - } - - :host([stack][containment='viewport']) [part='detail'] { - padding-left: var(--safe-area-inset-left); - padding-right: var(--safe-area-inset-right); - } - - /* Sizing styles */ - - [part] { + [part~='master'], + [part~='detail'] { box-sizing: border-box; - max-width: 100%; - max-height: 100%; - } - - /* No fixed size */ - :host(:not([has-master-size])) [part='master'], - :host(:not([has-detail-size]):not([drawer], [stack])) [part='detail'] { - flex-grow: 1; - flex-basis: 50%; } - /* Fixed size */ - :host([has-master-size]) [part='master'], - :host([has-detail-size]) [part='detail'] { - flex-shrink: 0; + [part~='master'] { + grid-column: master-start / detail-start; } - :host([orientation='horizontal'][has-master-size][has-detail]) [part='master'] { - width: var(--_master-size); + [part~='detail'] { + grid-column: detail-start / detail-end; } - :host([orientation='vertical'][has-master-size][has-detail]) [part='master'] { - height: var(--_master-size); + :host([orientation='vertical']) [part~='master'] { + grid-column: auto; + grid-row: master-start / detail-start; } - :host([orientation='horizontal'][has-detail-size]:not([stack])) [part='detail'] { - width: var(--_detail-size); + :host([orientation='vertical']) [part~='detail'] { + grid-column: auto; + grid-row: detail-start / detail-end; } - :host([orientation='vertical'][has-detail-size]:not([stack])) [part='detail'] { - height: var(--_detail-size); - } - - :host([has-master-size][has-detail-size]) [part='master'] { - flex-grow: 1; - flex-basis: var(--_master-size); + [part~='backdrop'] { + position: absolute; + inset: 0; + z-index: 1; + display: none; + background: var(--vaadin-overlay-backdrop-background, rgba(0, 0, 0, 0.2)); + forced-color-adjust: none; } - :host([has-master-size][has-detail-size]:not([drawer], [stack])) [part='detail'] { - flex-grow: 1; - flex-basis: var(--_detail-size); + :host([expand='both']), + :host([expand='master']) { + --_master-column: var(--_master-size) 1fr; } - /* Min size */ - :host([orientation='horizontal'][has-master-min-size]) [part='master'] { - min-width: min(100%, var(--_master-min-size)); + :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)); } - :host([orientation='vertical'][has-master-min-size]) [part='master'] { - min-height: min(100%, var(--_master-min-size)); + :host([expand='both']), + :host([expand='detail']) { + --_detail-column: var(--_detail-size) 1fr; } - :host([orientation='horizontal'][has-detail-min-size]) [part='detail'] { - min-width: min(100%, var(--_detail-min-size)); + :host([orientation='horizontal'][has-detail]:not([overflow])) [part~='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'][has-detail-min-size]) [part='detail'] { - min-height: min(100%, var(--_detail-min-size)); + :host([orientation='vertical'][has-detail]:not([overflow])) [part~='detail'] { + border-top: var(--vaadin-master-detail-layout-border-width, 1px) solid + var(--vaadin-master-detail-layout-border-color, var(--vaadin-border-color-secondary)); } - :host([drawer]) [part='master'], - :host([stack]) [part] { - width: 100% !important; - height: 100% !important; - min-width: auto !important; - min-height: auto !important; - max-width: 100% !important; - max-height: 100% !important; + :host([overflow]) [part~='detail'] { + position: absolute; + z-index: 2; + background: var(--vaadin-master-detail-layout-detail-background, var(--vaadin-background-color)); + box-shadow: var(--vaadin-master-detail-layout-detail-shadow, 0 0 20px 0 rgba(0, 0, 0, 0.3)); + grid-column: none; } - /* Decorative/visual styles */ - - [part='backdrop'] { - background: var(--vaadin-overlay-backdrop-background, rgba(0, 0, 0, 0.2)); - forced-color-adjust: none; + :host([overflow]) [part~='backdrop'] { + display: block; } - :host(:is([drawer], [stack])) [part='detail'] { - background: var(--vaadin-master-detail-layout-detail-background, var(--vaadin-background-color)); - box-shadow: var(--vaadin-master-detail-layout-detail-shadow, 0 0 20px 0 rgba(0, 0, 0, 0.3)); + :host([overflow]:not([orientation='vertical'])) [part~='detail'] { + inset-block: 0; + width: var(--_overlay-size, var(--_detail-size, min-content)); + inset-inline-end: 0; } - :host([orientation='horizontal']:not([drawer], [stack])) [part='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([overflow][orientation='vertical']) [part~='detail'] { + grid-column: auto; + grid-row: none; + inset-inline: 0; + height: var(--_overlay-size, var(--_detail-size, min-content)); + inset-block-end: 0; } - :host([orientation='vertical']:not([drawer], [stack])) [part='detail'] { - border-top: var(--vaadin-master-detail-layout-border-width, 1px) solid - var(--vaadin-master-detail-layout-border-color, var(--vaadin-border-color-secondary)); + :host([overflow][overlay-containment='viewport']) [part~='detail'], + :host([overflow][overlay-containment='viewport']) [part~='backdrop'] { + position: fixed; } @media (forced-colors: active) { - :host(:is([drawer], [stack])) [part='detail'] { + :host([overflow]) [part~='detail'] { outline: 3px solid !important; } - [part='detail'] { + [part~='detail'] { background: Canvas !important; } } diff --git a/packages/master-detail-layout/src/styles/vaadin-master-detail-layout-transition-base-styles.js b/packages/master-detail-layout/src/styles/vaadin-master-detail-layout-transition-base-styles.js index 61c4f2fcbb7..9d911c74067 100644 --- a/packages/master-detail-layout/src/styles/vaadin-master-detail-layout-transition-base-styles.js +++ b/packages/master-detail-layout/src/styles/vaadin-master-detail-layout-transition-base-styles.js @@ -9,14 +9,11 @@ export const masterDetailLayoutTransitionStyles = css` @media (prefers-reduced-motion: no-preference) { html { --_vaadin-mdl-dir-multiplier: 1; - --_vaadin-mdl-stack-master-offset: 20%; - --_vaadin-mdl-stack-master-clip-path: inset(0 0 0 var(--_vaadin-mdl-stack-master-offset)); --_vaadin-mdl-easing: cubic-bezier(0.78, 0, 0.22, 1); } html[dir='rtl'] { --_vaadin-mdl-dir-multiplier: -1; - --_vaadin-mdl-stack-master-clip-path: inset(0 var(--_vaadin-mdl-stack-master-offset) 0 0); } ::view-transition-group(vaadin-mdl-backdrop), @@ -44,8 +41,8 @@ export const masterDetailLayoutTransitionStyles = css` view-transition-name: vaadin-mdl-backdrop; } - vaadin-master-detail-layout[transition]:not([transition='replace']):not([drawer], [stack])::part(detail), - vaadin-master-detail-layout[transition]:is([drawer], [stack])::part(_detail-internal) { + vaadin-master-detail-layout[transition][has-detail]:not([transition='replace']):not([overflow])::part(detail), + vaadin-master-detail-layout[transition][has-detail][overflow]::part(detail) { view-transition-name: vaadin-mdl-detail; } @@ -68,23 +65,10 @@ export const masterDetailLayoutTransitionStyles = css` } } - vaadin-master-detail-layout[orientation='horizontal'][stack][has-detail]::part(master) { - translate: calc(var(--_vaadin-mdl-stack-master-offset) * var(--_vaadin-mdl-dir-multiplier) * -1); - opacity: 0; - } - vaadin-master-detail-layout[transition]::part(master) { view-transition-name: vaadin-mdl-master; } - vaadin-master-detail-layout[orientation='horizontal'][stack][transition='add']::part(master) { - view-transition-class: stack-add; - } - - vaadin-master-detail-layout[orientation='horizontal'][stack][transition='remove']::part(master) { - view-transition-class: stack-remove; - } - ::view-transition-new(vaadin-mdl-master), ::view-transition-old(vaadin-mdl-master) { object-fit: none; @@ -98,33 +82,9 @@ export const masterDetailLayoutTransitionStyles = css` object-position: 100% 0; } - ::view-transition-new(vaadin-mdl-master.stack-remove), - ::view-transition-old(vaadin-mdl-master.stack-remove) { - animation-name: vaadin-mdl-master-stack-remove; - clip-path: var(--_vaadin-mdl-stack-master-clip-path); - } - - @keyframes vaadin-mdl-master-stack-remove { - 100% { - clip-path: inset(0); - } - } - - ::view-transition-new(vaadin-mdl-master.stack-add), - ::view-transition-old(vaadin-mdl-master.stack-add) { - animation-name: vaadin-mdl-master-stack-add; - clip-path: inset(0); - } - - @keyframes vaadin-mdl-master-stack-add { - 100% { - clip-path: var(--_vaadin-mdl-stack-master-clip-path); - } - } - /* prettier-ignore */ - vaadin-master-detail-layout[orientation='vertical']:not([drawer], [stack])[transition]:not([transition='replace'])::part(detail), - vaadin-master-detail-layout[orientation='vertical']:is([drawer], [stack])[transition]::part(_detail-internal) { + vaadin-master-detail-layout[orientation='vertical'][has-detail]:not([overflow])[transition]:not([transition='replace'])::part(detail), + vaadin-master-detail-layout[orientation='vertical'][has-detail][overflow][transition]::part(detail) { view-transition-name: vaadin-mdl-detail; view-transition-class: vertical; } diff --git a/packages/master-detail-layout/src/vaadin-master-detail-layout.d.ts b/packages/master-detail-layout/src/vaadin-master-detail-layout.d.ts index 5acf2aec541..c3e9d8decb2 100644 --- a/packages/master-detail-layout/src/vaadin-master-detail-layout.d.ts +++ b/packages/master-detail-layout/src/vaadin-master-detail-layout.d.ts @@ -4,7 +4,6 @@ * This program is available under Apache License Version 2.0, available at https://vaadin.com/license/ */ import { ElementMixin } from '@vaadin/component-base/src/element-mixin.js'; -import { ResizeMixin } from '@vaadin/component-base/src/resize-mixin.js'; import { SlotStylesMixin } from '@vaadin/component-base/src/slot-styles-mixin.js'; import { ThemableMixin } from '@vaadin/vaadin-themable-mixin/vaadin-themable-mixin.js'; @@ -27,19 +26,19 @@ export interface MasterDetailLayoutEventMap extends HTMLElementEventMap, MasterD * * Part name | Description * ---------------|---------------------- - * `backdrop` | Backdrop covering the master area in the drawer mode + * `backdrop` | Backdrop covering the master area in the overlay mode * `master` | The master area * `detail` | The detail area * * The following state attributes are available for styling: * - * Attribute | Description - * ---------------| ----------- - * `containment` | Set to `layout` or `viewport` depending on the containment. - * `orientation` | Set to `horizontal` or `vertical` depending on the orientation. - * `has-detail` | Set when the detail content is provided. - * `drawer` | Set when the layout is using the drawer mode. - * `stack` | Set when the layout is using the stack mode. + * Attribute | Description + * ----------------------|---------------------- + * `expand` | Set to `master`, `detail`, or `both`. + * `orientation` | Set to `horizontal` or `vertical` depending on the orientation. + * `has-detail` | Set when the detail content is provided and visible. + * `overflow` | Set when columns don't fit and the detail is shown as an overlay. + * `overlay-containment` | Set to `layout` or `viewport`. * * The following custom CSS properties are available for styling: * @@ -53,53 +52,38 @@ export interface MasterDetailLayoutEventMap extends HTMLElementEventMap, MasterD * * See [Styling Components](https://vaadin.com/docs/latest/styling/styling-components) documentation. * - * @fires {CustomEvent} backdrop-click - Fired when the user clicks the backdrop in the drawer mode. + * @fires {CustomEvent} backdrop-click - Fired when the user clicks the backdrop in the overlay mode. * @fires {CustomEvent} detail-escape-press - Fired when the user presses Escape in the detail area. */ -declare class MasterDetailLayout extends SlotStylesMixin(ResizeMixin(ThemableMixin(ElementMixin(HTMLElement)))) { +declare class MasterDetailLayout extends SlotStylesMixin(ThemableMixin(ElementMixin(HTMLElement))) { /** - * Fixed size (in CSS length units) to be set on the detail area. - * When specified, it prevents the detail area from growing or - * shrinking. If there is not enough space to show master and detail - * areas next to each other, the details are shown as an overlay: - * either as drawer or stack, depending on the `stackOverlay` property. + * 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 + * master and detail areas next to each other, the detail area + * is shown as an overlay. Defaults to 15em. * * @attr {string} detail-size */ detailSize: string | null | undefined; /** - * Minimum size (in CSS length units) to be set on the detail area. - * When specified, it prevents the detail area from shrinking below - * this size. If there is not enough space to show master and detail - * areas next to each other, the details are shown as an overlay: - * either as drawer or stack, depending on the `stackOverlay` property. - * - * @attr {string} detail-min-size - */ - detailMinSize: string | null | undefined; - - /** - * Fixed size (in CSS length units) to be set on the master area. - * When specified, it prevents the master area from growing or - * shrinking. If there is not enough space to show master and detail - * areas next to each other, the details are shown as an overlay: - * either as drawer or stack, depending on the `stackOverlay` property. + * Size (in CSS length units) to be set on the master area in + * the CSS grid layout. If 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 30em. * * @attr {string} master-size */ masterSize: string | null | undefined; /** - * Minimum size (in CSS length units) to be set on the master area. - * When specified, it prevents the master area from shrinking below - * this size. If there is not enough space to show master and detail - * areas next to each other, the details are shown as an overlay: - * either as drawer or stack, depending on the `stackOverlay` property. + * Size (in CSS length units) for the detail area when shown as an + * overlay. When not set, falls back to `detailSize`. Set to `100%` + * to make the detail cover the full layout. * - * @attr {string} master-min-size + * @attr {string} overlay-size */ - masterMinSize: string | null | undefined; + overlaySize: string | null | undefined; /** * Define how master and detail areas are shown next to each other, @@ -109,38 +93,22 @@ declare class MasterDetailLayout extends SlotStylesMixin(ResizeMixin(ThemableMix */ orientation: 'horizontal' | 'vertical'; - /** - * When specified, forces the details to be shown as an overlay - * (either as drawer or stack), even if there is enough space for - * master and detail to be shown next to each other using the default - * (split) mode. - * - * In order to enforce the stack mode, use this property together with - * `stackOverlay` property and set both to `true`. - * - * @attr {boolean} force-overlay - */ - forceOverlay: boolean; - /** * Defines the containment of the detail area when the layout is in * overlay mode. When set to `layout`, the overlay is confined to the * layout. When set to `viewport`, the overlay is confined to the * browser's viewport. Defaults to `layout`. + * + * @attr {string} overlay-containment */ - containment: 'layout' | 'viewport'; + overlayContainment: 'layout' | 'viewport'; /** - * When true, the layout in the overlay mode is rendered as a stack, - * making detail area fully cover the master area. Otherwise, it is - * rendered as a drawer and has a visual backdrop. - * - * In order to enforce the stack mode, use this property together with - * `forceOverlay` property and set both to `true`. - * - * @attr {string} stack-threshold + * Controls which column(s) expand to fill available space. + * Possible values: `'master'`, `'detail'`, `'both'`. + * Defaults to `'both'`. */ - stackOverlay: boolean; + expand: 'master' | 'detail' | 'both'; /** * When true, the layout does not use animated transitions for the detail area. diff --git a/packages/master-detail-layout/src/vaadin-master-detail-layout.js b/packages/master-detail-layout/src/vaadin-master-detail-layout.js index 1a18f126489..a1bf9b4259d 100644 --- a/packages/master-detail-layout/src/vaadin-master-detail-layout.js +++ b/packages/master-detail-layout/src/vaadin-master-detail-layout.js @@ -8,12 +8,20 @@ import { getFocusableElements } from '@vaadin/a11y-base/src/focus-utils.js'; import { defineCustomElement } from '@vaadin/component-base/src/define.js'; import { ElementMixin } from '@vaadin/component-base/src/element-mixin.js'; import { PolylitMixin } from '@vaadin/component-base/src/polylit-mixin.js'; -import { ResizeMixin } from '@vaadin/component-base/src/resize-mixin.js'; import { SlotStylesMixin } from '@vaadin/component-base/src/slot-styles-mixin.js'; import { ThemableMixin } from '@vaadin/vaadin-themable-mixin/vaadin-themable-mixin.js'; import { masterDetailLayoutStyles } from './styles/vaadin-master-detail-layout-base-styles.js'; import { masterDetailLayoutTransitionStyles } from './styles/vaadin-master-detail-layout-transition-base-styles.js'; +function parseTrackSizes(gridTemplate) { + return gridTemplate + .replace(/\[[^\]]+\]/gu, '') + .replace(/\s+/gu, ' ') + .trim() + .split(' ') + .map(parseFloat); +} + /** * `` is a web component for building UIs with a master * (or primary) area and a detail (or secondary) area that is displayed next to, or @@ -25,19 +33,19 @@ import { masterDetailLayoutTransitionStyles } from './styles/vaadin-master-detai * * Part name | Description * ---------------|---------------------- - * `backdrop` | Backdrop covering the master area in the drawer mode + * `backdrop` | Backdrop covering the master area in the overlay mode * `master` | The master area * `detail` | The detail area * * The following state attributes are available for styling: * - * Attribute | Description - * ---------------| ----------- - * `containment` | Set to `layout` or `viewport` depending on the containment. - * `orientation` | Set to `horizontal` or `vertical` depending on the orientation. - * `has-detail` | Set when the detail content is provided. - * `drawer` | Set when the layout is using the drawer mode. - * `stack` | Set when the layout is using the stack mode. + * Attribute | Description + * ----------------------|---------------------- + * `expand` | Set to `master`, `detail`, or `both`. + * `orientation` | Set to `horizontal` or `vertical` depending on the orientation. + * `has-detail` | Set when the detail content is provided and visible. + * `overflow` | Set when columns don't fit and the detail is shown as an overlay. + * `overlay-containment` | Set to `layout` or `viewport`. * * The following custom CSS properties are available for styling: * @@ -51,17 +59,16 @@ import { masterDetailLayoutTransitionStyles } from './styles/vaadin-master-detai * * See [Styling Components](https://vaadin.com/docs/latest/styling/styling-components) documentation. * - * @fires {CustomEvent} backdrop-click - Fired when the user clicks the backdrop in the drawer mode. + * @fires {CustomEvent} backdrop-click - Fired when the user clicks the backdrop in the overlay mode. * @fires {CustomEvent} detail-escape-press - Fired when the user presses Escape in the detail area. * * @customElement vaadin-master-detail-layout * @extends HTMLElement * @mixes ThemableMixin * @mixes ElementMixin - * @mixes ResizeMixin * @mixes SlotStylesMixin */ -class MasterDetailLayout extends SlotStylesMixin(ResizeMixin(ElementMixin(ThemableMixin(PolylitMixin(LitElement))))) { +class MasterDetailLayout extends SlotStylesMixin(ElementMixin(ThemableMixin(PolylitMixin(LitElement)))) { static get is() { return 'vaadin-master-detail-layout'; } @@ -73,11 +80,10 @@ class MasterDetailLayout extends SlotStylesMixin(ResizeMixin(ElementMixin(Themab static get properties() { return { /** - * Fixed size (in CSS length units) to be set on the detail area. - * When specified, it prevents the detail area from growing or - * shrinking. If there is not enough space to show master and detail - * areas next to each other, the details are shown as an overlay: - * either as drawer or stack, depending on the `stackOverlay` property. + * 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 + * master and detail areas next to each other, the detail area + * is shown as an overlay. Defaults to 15em. * * @attr {string} detail-size */ @@ -88,26 +94,10 @@ class MasterDetailLayout extends SlotStylesMixin(ResizeMixin(ElementMixin(Themab }, /** - * Minimum size (in CSS length units) to be set on the detail area. - * When specified, it prevents the detail area from shrinking below - * this size. If there is not enough space to show master and detail - * areas next to each other, the details are shown as an overlay: - * either as drawer or stack, depending on the `stackOverlay` property. - * - * @attr {string} detail-min-size - */ - detailMinSize: { - type: String, - sync: true, - observer: '__detailMinSizeChanged', - }, - - /** - * Fixed size (in CSS length units) to be set on the master area. - * When specified, it prevents the master area from growing or - * shrinking. If there is not enough space to show master and detail - * areas next to each other, the details are shown as an overlay: - * either as drawer or stack, depending on the `stackOverlay` property. + * Size (in CSS length units) to be set on the master area in + * the CSS grid layout. If 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 30em. * * @attr {string} master-size */ @@ -118,18 +108,16 @@ class MasterDetailLayout extends SlotStylesMixin(ResizeMixin(ElementMixin(Themab }, /** - * Minimum size (in CSS length units) to be set on the master area. - * When specified, it prevents the master area from shrinking below - * this size. If there is not enough space to show master and detail - * areas next to each other, the details are shown as an overlay: - * either as drawer or stack, depending on the `stackOverlay` property. + * Size (in CSS length units) for the detail area when shown as an + * overlay. When not set, falls back to `detailSize`. Set to `100%` + * to make the detail cover the full layout. * - * @attr {string} master-min-size + * @attr {string} overlay-size */ - masterMinSize: { + overlaySize: { type: String, sync: true, - observer: '__masterMinSizeChanged', + observer: '__overlaySizeChanged', }, /** @@ -142,25 +130,6 @@ class MasterDetailLayout extends SlotStylesMixin(ResizeMixin(ElementMixin(Themab type: String, value: 'horizontal', reflectToAttribute: true, - observer: '__orientationChanged', - sync: true, - }, - - /** - * When specified, forces the details to be shown as an overlay - * (either as drawer or stack), even if there is enough space for - * master and detail to be shown next to each other using the default - * (split) mode. - * - * In order to enforce the stack mode, use this property together with - * `stackOverlay` property and set both to `true`. - * - * @attr {boolean} force-overlay - */ - forceOverlay: { - type: Boolean, - value: false, - observer: '__forceOverlayChanged', sync: true, }, @@ -169,8 +138,10 @@ class MasterDetailLayout extends SlotStylesMixin(ResizeMixin(ElementMixin(Themab * overlay mode. When set to `layout`, the overlay is confined to the * layout. When set to `viewport`, the overlay is confined to the * browser's viewport. Defaults to `layout`. + * + * @attr {string} overlay-containment */ - containment: { + overlayContainment: { type: String, value: 'layout', reflectToAttribute: true, @@ -178,19 +149,14 @@ class MasterDetailLayout extends SlotStylesMixin(ResizeMixin(ElementMixin(Themab }, /** - * When true, the layout in the overlay mode is rendered as a stack, - * making detail area fully cover the master area. Otherwise, it is - * rendered as a drawer and has a visual backdrop. - * - * In order to enforce the stack mode, use this property together with - * `forceOverlay` property and set both to `true`. - * - * @attr {string} stack-threshold + * Controls which column(s) expand to fill available space. + * Possible values: `'master'`, `'detail'`, `'both'`. + * Defaults to `'both'`. */ - stackOverlay: { - type: Boolean, - value: false, - observer: '__stackOverlayChanged', + expand: { + type: String, + value: 'both', + reflectToAttribute: true, sync: true, }, @@ -203,39 +169,6 @@ class MasterDetailLayout extends SlotStylesMixin(ResizeMixin(ElementMixin(Themab type: Boolean, value: false, }, - - /** - * When true, the component uses the drawer mode. This property is read-only. - * @protected - */ - _drawer: { - type: Boolean, - attribute: 'drawer', - reflectToAttribute: true, - sync: true, - }, - - /** - * When true, the component uses the stack mode. This property is read-only. - * @protected - */ - _stack: { - type: Boolean, - attribute: 'stack', - reflectToAttribute: true, - sync: true, - }, - - /** - * When true, the component has the detail content provided. - * @protected - */ - _hasDetail: { - type: Boolean, - attribute: 'has-detail', - reflectToAttribute: true, - sync: true, - }, }; } @@ -243,194 +176,168 @@ class MasterDetailLayout extends SlotStylesMixin(ResizeMixin(ElementMixin(Themab return true; } - /** @override */ + /** @return {!Array} */ get slotStyles() { return [masterDetailLayoutTransitionStyles]; } /** @protected */ render() { + const isOverlay = this.hasAttribute('has-detail') && this.hasAttribute('overflow'); + const isViewport = isOverlay && this.overlayContainment === 'viewport'; + const isLayoutContained = isOverlay && !isViewport; + return html`
+
+ +
- -
-
-
- -
+
`; } - /** @private */ - __onDetailSlotChange(e) { - const children = e.target.assignedNodes(); - - this._hasDetail = children.length > 0; - this.__detectLayoutMode(); - - // Move focus to the detail area when it is added to the DOM, - // in case if the layout is using drawer or stack mode. - if ((this._drawer || this._stack) && children.length > 0) { - const focusables = getFocusableElements(children[0]); - if (focusables.length) { - focusables[0].focus(); - } - } + /** @protected */ + connectedCallback() { + super.connectedCallback(); + this.__initResizeObserver(); } - /** @private */ - __onBackdropClick() { - this.dispatchEvent(new CustomEvent('backdrop-click')); + /** @protected */ + disconnectedCallback() { + super.disconnectedCallback(); + this.__resizeObserver.disconnect(); + cancelAnimationFrame(this.__resizeRaf); } /** @private */ - __onDetailKeydown(event) { - if (event.key === 'Escape' && !event.defaultPrevented) { - // Prevent firing on parent layout when using nested layouts - event.preventDefault(); - this.dispatchEvent(new CustomEvent('detail-escape-press')); - } - } - - /** - * @protected - * @override - */ - _onResize() { - this.__detectLayoutMode(); + __masterSizeChanged(size, oldSize) { + this.__updateStyleProperty('master-size', size, oldSize); } /** @private */ __detailSizeChanged(size, oldSize) { this.__updateStyleProperty('detail-size', size, oldSize); - this.__detectLayoutMode(); } /** @private */ - __detailMinSizeChanged(size, oldSize) { - this.__updateStyleProperty('detail-min-size', size, oldSize); - this.__detectLayoutMode(); + __overlaySizeChanged(size, oldSize) { + this.__updateStyleProperty('overlay-size', size, oldSize); } /** @private */ - __masterSizeChanged(size, oldSize) { - this.__updateStyleProperty('master-size', size, oldSize); - this.__detectLayoutMode(); + __updateStyleProperty(prop, size, oldSize) { + if (size) { + this.style.setProperty(`--_${prop}`, size); + } else if (oldSize) { + this.style.removeProperty(`--_${prop}`); + } } /** @private */ - __masterMinSizeChanged(size, oldSize) { - this.__updateStyleProperty('master-min-size', size, oldSize); - this.__detectLayoutMode(); + __onSlotChange() { + this.__initResizeObserver(); } /** @private */ - __orientationChanged(orientation, oldOrientation) { - if (orientation || oldOrientation) { - this.__detectLayoutMode(); - } + __initResizeObserver() { + this.__resizeObserver = this.__resizeObserver || new ResizeObserver(() => this.__onResize()); + this.__resizeObserver.disconnect(); + + const children = this.querySelectorAll(':scope > [slot="detail"], :scope >:not([slot])'); + [this, this.$.master, this.$.detail, ...children].forEach((node) => { + this.__resizeObserver.observe(node); + }); } - /** @private */ - __forceOverlayChanged(forceOverlay, oldForceOverlay) { - if (forceOverlay || oldForceOverlay) { - this.__detectLayoutMode(); - } + /** + * Called by the ResizeObserver. Reads layout state synchronously (no forced + * reflow since layout is already computed), then defers writes to rAF. + * Cancels any pending rAF so the write phase always uses the latest state. + * @private + */ + __onResize() { + const state = this.__computeLayoutState(); + cancelAnimationFrame(this.__resizeRaf); + this.__resizeRaf = requestAnimationFrame(() => this.__applyLayoutState(state)); } - /** @private */ - __stackOverlayChanged(stackOverlay, oldStackOverlay) { - if (stackOverlay || oldStackOverlay) { - this.__detectLayoutMode(); - } + /** + * Reads DOM/style state needed for layout detection. Safe to call in + * ResizeObserver callback where layout is already computed (no forced reflow). + * @private + */ + __computeLayoutState() { + const detailContent = this.querySelector('[slot="detail"]'); + const hadDetail = this.hasAttribute('has-detail'); + const hasDetail = detailContent != null && detailContent.checkVisibility(); + const hasOverflow = hasDetail && this.__checkOverflow(); + const focusTarget = !hadDetail && hasDetail && hasOverflow ? getFocusableElements(detailContent)[0] : null; + return { hadDetail, hasDetail, hasOverflow, focusTarget }; } - /** @private */ - __updateStyleProperty(prop, size, oldSize) { - if (size) { - this.style.setProperty(`--_${prop}`, size); - } else if (oldSize) { - this.style.removeProperty(`--_${prop}`); + /** + * Applies layout state to DOM attributes. Pure writes, no reads. + * @private + */ + __applyLayoutState({ hadDetail, hasDetail, hasOverflow, focusTarget }) { + // Set preserve-master-width when detail first appears with overflow + // to prevent master width from jumping. + if (!hadDetail && hasDetail && hasOverflow) { + this.setAttribute('preserve-master-width', ''); + } else if (!hasDetail || !hasOverflow) { + this.removeAttribute('preserve-master-width'); } - this.toggleAttribute(`has-${prop}`, !!size); - } + this.toggleAttribute('has-detail', hasDetail); + this.toggleAttribute('overflow', hasOverflow); - /** @private */ - __setOverlayMode(value) { - if (this.stackOverlay) { - this._stack = value; - } else { - this._drawer = value; + // Re-render to update ARIA attributes (role, aria-modal, inert) + // which depend on has-detail and overflow state. + this.requestUpdate(); + + if (focusTarget) { + focusTarget.focus(); } } /** @private */ - __detectLayoutMode() { - this._drawer = false; - this._stack = false; + __checkOverflow() { + const isVertical = this.orientation === 'vertical'; + const computedStyle = getComputedStyle(this); - if (this.forceOverlay) { - this.__setOverlayMode(true); - return; - } + const hostSize = parseFloat(computedStyle[isVertical ? 'height' : 'width']); + const [masterSize, masterExtra, detailSize] = parseTrackSizes( + computedStyle[isVertical ? 'gridTemplateRows' : 'gridTemplateColumns'], + ); - if (!this._hasDetail) { - return; + if (Math.floor(masterSize + masterExtra + detailSize) <= Math.floor(hostSize)) { + return false; } - - if (this.orientation === 'vertical') { - this.__detectVerticalMode(); - } else { - this.__detectHorizontalMode(); + if (Math.floor(masterExtra) >= Math.floor(detailSize)) { + return false; } + return true; } /** @private */ - __detectHorizontalMode() { - const detailWidth = this.$.detail.offsetWidth; - - // Detect minimum width needed by master content. Use max-width to ensure - // the layout can switch back to split mode once there is enough space. - // If there is master size or min-size set, use that instead to force the - // overlay mode by setting `masterSize` / `masterMinSize` to 100%/ - this.$.master.style.maxWidth = this.masterSize || this.masterMinSize || 'min-content'; - const masterWidth = this.$.master.offsetWidth; - this.$.master.style.maxWidth = ''; - - // If the combined minimum size of both the master and the detail content - // exceeds the size of the layout, the layout changes to the overlay mode. - this.__setOverlayMode(this.offsetWidth < masterWidth + detailWidth); - - // Toggling the overlay resizes master content, which can cause document - // scroll bar to appear or disappear, and trigger another resize of the - // layout which can affect previous measurements and end up in horizontal - // scroll. Check if that is the case and if so, preserve the overlay mode. - if (this.offsetWidth < this.scrollWidth) { - this.__setOverlayMode(true); - } + __onBackdropClick() { + this.dispatchEvent(new CustomEvent('backdrop-click')); } /** @private */ - __detectVerticalMode() { - const masterHeight = this.$.master.clientHeight; - - // If the combined minimum size of both the master and the detail content - // exceeds the available height, the layout changes to the overlay mode. - if (this.offsetHeight < masterHeight + this.$.detail.clientHeight) { - this.__setOverlayMode(true); + __onDetailKeydown(event) { + if (event.key === 'Escape' && !event.defaultPrevented) { + // Prevent firing on parent layout when using nested layouts + event.preventDefault(); + this.dispatchEvent(new CustomEvent('detail-escape-press')); } } @@ -529,10 +436,13 @@ class MasterDetailLayout extends SlotStylesMixin(ResizeMixin(ElementMixin(Themab * @protected */ async _finishTransition() { - // Detect new layout mode after DOM has been updated. - // The detection is wrapped in queueMicroTask in order to allow custom Lit elements to render before measurement. - // https://github.com/vaadin/web-components/issues/8969 - queueMicrotask(() => this.__detectLayoutMode()); + // Detect layout mode before resolving the transition, so the browser's + // "new" snapshot includes the correct overlay state. The microtask runs + // before the Promise resolution propagates to startViewTransition. + queueMicrotask(() => { + const state = this.__computeLayoutState(); + this.__applyLayoutState(state); + }); if (!this.__transition) { return Promise.resolve(); @@ -547,7 +457,7 @@ class MasterDetailLayout extends SlotStylesMixin(ResizeMixin(ElementMixin(Themab /** * @event backdrop-click - * Fired when the user clicks the backdrop in the drawer mode. + * Fired when the user clicks the backdrop in the overlay mode. */ /** diff --git a/packages/master-detail-layout/test/aria.test.js b/packages/master-detail-layout/test/aria.test.js index dcde6c70826..bda24cce834 100644 --- a/packages/master-detail-layout/test/aria.test.js +++ b/packages/master-detail-layout/test/aria.test.js @@ -1,8 +1,9 @@ import { expect } from '@vaadin/chai-plugins'; -import { fixtureSync, nextRender } from '@vaadin/testing-helpers'; +import { fixtureSync } from '@vaadin/testing-helpers'; import '../src/vaadin-master-detail-layout.js'; import './helpers/master-content.js'; import './helpers/detail-content.js'; +import { onceResized } from './helpers.js'; window.Vaadin ||= {}; window.Vaadin.featureFlags ||= {}; @@ -13,65 +14,50 @@ describe('ARIA', () => { beforeEach(async () => { layout = fixtureSync(` - + `); - await nextRender(); + await onceResized(layout); master = layout.shadowRoot.querySelector('[part="master"]'); detail = layout.shadowRoot.querySelector('[part="detail"]'); }); - it('should set role to dialog on the detail part in the drawer mode', () => { - layout.forceOverlay = true; + it('should set role="dialog" on detail in overlay mode', () => { + expect(layout.hasAttribute('overflow')).to.be.true; expect(detail.getAttribute('role')).to.equal('dialog'); - - layout.forceOverlay = false; - expect(detail.hasAttribute('role')).to.be.false; }); - it('should set role to dialog on the detail part in the stack mode', () => { - layout.forceOverlay = true; - layout.stackOverlay = true; - expect(detail.getAttribute('role')).to.equal('dialog'); - - layout.forceOverlay = false; - + it('should remove role="dialog" when switching from overlay to split mode', async () => { + layout.style.width = '800px'; + await onceResized(layout); expect(detail.hasAttribute('role')).to.be.false; }); - it('should set aria-model on the detail part with the viewport containment', () => { - layout.forceOverlay = true; - layout.containment = 'viewport'; + it('should set aria-modal on detail with viewport containment', async () => { + layout.overlayContainment = 'viewport'; + await onceResized(layout); expect(detail.getAttribute('aria-modal')).to.equal('true'); + }); - layout.containment = 'layout'; + it('should not set aria-modal on detail with layout containment', () => { expect(detail.hasAttribute('aria-modal')).to.be.false; }); - it('should set inert on the master part with the layout containment', () => { - layout.forceOverlay = true; - layout.containment = 'layout'; + it('should set inert on master with layout containment', () => { expect(master.hasAttribute('inert')).to.be.true; + }); - layout.containment = 'viewport'; + it('should not set inert on master with viewport containment', async () => { + layout.overlayContainment = 'viewport'; + await onceResized(layout); expect(master.hasAttribute('inert')).to.be.false; }); - it('should not set inert on the master part with the detail removed', async () => { - layout.forceOverlay = true; - layout.containment = 'layout'; - - const detailContent = layout.querySelector('[slot="detail"]'); - detailContent.remove(); - await nextRender(); - + it('should not set inert on master when detail is removed', async () => { + layout.querySelector('[slot="detail"]').remove(); + await onceResized(layout); expect(master.hasAttribute('inert')).to.be.false; - - layout.appendChild(detailContent); - await nextRender(); - - expect(master.hasAttribute('inert')).to.be.true; }); }); diff --git a/packages/master-detail-layout/test/dom/__snapshots__/master-detail-layout.test.snap.js b/packages/master-detail-layout/test/dom/__snapshots__/master-detail-layout.test.snap.js index 67aa1ccf036..9398705028e 100644 --- a/packages/master-detail-layout/test/dom/__snapshots__/master-detail-layout.test.snap.js +++ b/packages/master-detail-layout/test/dom/__snapshots__/master-detail-layout.test.snap.js @@ -1,5 +1,47 @@ /* @web/test-runner snapshot v1 */ export const snapshots = {}; +snapshots["vaadin-master-detail-layout host default"] = +` + +`; +/* end snapshot vaadin-master-detail-layout host default */ + +snapshots["vaadin-master-detail-layout host masterSize"] = +` + +`; +/* end snapshot vaadin-master-detail-layout host masterSize */ + +snapshots["vaadin-master-detail-layout host detailSize"] = +` + +`; +/* end snapshot vaadin-master-detail-layout host detailSize */ + +snapshots["vaadin-master-detail-layout host masterSize and detailSize"] = +` + +`; +/* end snapshot vaadin-master-detail-layout host masterSize and detailSize */ snapshots["vaadin-master-detail-layout shadow default"] = `
@@ -11,14 +53,12 @@ snapshots["vaadin-master-detail-layout shadow default"] =
-
-
- - -
+
+ +
`; /* end snapshot vaadin-master-detail-layout shadow default */ diff --git a/packages/master-detail-layout/test/dom/master-detail-layout.test.js b/packages/master-detail-layout/test/dom/master-detail-layout.test.js index 616527ffaf2..0c7fff6a4fc 100644 --- a/packages/master-detail-layout/test/dom/master-detail-layout.test.js +++ b/packages/master-detail-layout/test/dom/master-detail-layout.test.js @@ -1,5 +1,5 @@ import { expect } from '@vaadin/chai-plugins'; -import { fixtureSync } from '@vaadin/testing-helpers'; +import { fixtureSync, nextFrame } from '@vaadin/testing-helpers'; import '../../src/vaadin-master-detail-layout.js'; window.Vaadin ||= {}; @@ -9,8 +9,31 @@ window.Vaadin.featureFlags.masterDetailLayoutComponent = true; describe('vaadin-master-detail-layout', () => { let layout; - beforeEach(() => { + beforeEach(async () => { layout = fixtureSync(''); + await nextFrame(); + }); + + describe('host', () => { + it('default', async () => { + await expect(layout).dom.to.equalSnapshot(); + }); + + it('masterSize', async () => { + layout.masterSize = '300px'; + await expect(layout).dom.to.equalSnapshot(); + }); + + it('detailSize', async () => { + layout.detailSize = '400px'; + await expect(layout).dom.to.equalSnapshot(); + }); + + it('masterSize and detailSize', async () => { + layout.masterSize = '300px'; + layout.detailSize = '400px'; + await expect(layout).dom.to.equalSnapshot(); + }); }); describe('shadow', () => { diff --git a/packages/master-detail-layout/test/drawer-mode.test.js b/packages/master-detail-layout/test/drawer-mode.test.js deleted file mode 100644 index 2cce35ec729..00000000000 --- a/packages/master-detail-layout/test/drawer-mode.test.js +++ /dev/null @@ -1,434 +0,0 @@ -import { expect } from '@vaadin/chai-plugins'; -import { setViewport } from '@vaadin/test-runner-commands'; -import { fixtureSync, nextRender, nextResize } from '@vaadin/testing-helpers'; -import '../src/vaadin-master-detail-layout.js'; -import './helpers/master-content.js'; -import './helpers/detail-content.js'; -import { html, LitElement } from 'lit'; - -window.Vaadin ||= {}; -window.Vaadin.featureFlags ||= {}; -window.Vaadin.featureFlags.masterDetailLayoutComponent = true; - -describe('drawer mode', () => { - let layout, master, detail, detailWrapper, detailContent; - let width, height; - - before(() => { - width = window.innerWidth; - height = window.innerHeight; - }); - - beforeEach(async () => { - layout = fixtureSync(` - - - - - `); - await nextRender(); - master = layout.shadowRoot.querySelector('[part="master"]'); - detail = layout.shadowRoot.querySelector('[part="detail"]'); - detailWrapper = detail.parentElement; - detailContent = layout.querySelector('[slot="detail"]'); - }); - - afterEach(async () => { - await setViewport({ width, height }); - }); - - describe('default', () => { - it('should switch to the drawer mode when there is not enough space for both areas', async () => { - // Use the threshold at which the drawer mode is on by default. - await setViewport({ width: 350, height }); - await nextResize(layout); - - expect(layout.hasAttribute('drawer')).to.be.true; - expect(getComputedStyle(detailWrapper).position).to.equal('absolute'); - }); - - it('should switch to the drawer mode if not enough space when masterSize is set', async () => { - // Use the threshold at which the drawer mode isn't on by default, - // but will be on after setting fixed size on the master area. - await setViewport({ width: 400, height }); - await nextResize(layout); - - expect(layout.hasAttribute('drawer')).to.be.false; - - layout.masterSize = '300px'; - await nextResize(layout); - - expect(layout.hasAttribute('drawer')).to.be.true; - expect(getComputedStyle(detailWrapper).position).to.equal('absolute'); - }); - - it('should switch to the drawer mode if not enough space when masterMinSize is set', async () => { - // Use the threshold at which the drawer mode isn't on by default, - // but will be on after setting fixed size on the master area. - await setViewport({ width: 400, height }); - await nextResize(layout); - - expect(layout.hasAttribute('drawer')).to.be.false; - - layout.masterMinSize = '300px'; - await nextResize(layout); - - expect(layout.hasAttribute('drawer')).to.be.true; - expect(getComputedStyle(detailWrapper).position).to.equal('absolute'); - }); - - it('should set detail area width in drawer mode when detailSize is set', async () => { - // Use the threshold at which the drawer mode isn't on by default, - // but will be on after setting fixed size on the detail area. - await setViewport({ width: 500, height }); - await nextResize(layout); - - expect(layout.hasAttribute('drawer')).to.be.false; - - layout.detailSize = '300px'; - await nextResize(layout); - - expect(layout.hasAttribute('drawer')).to.be.true; - expect(getComputedStyle(detailWrapper).position).to.equal('absolute'); - expect(getComputedStyle(detail).width).to.equal('300px'); - }); - - it('should switch to the drawer mode when masterSize is set to 100%', async () => { - layout.masterSize = '100%'; - await nextResize(layout); - expect(layout.hasAttribute('drawer')).to.be.true; - }); - - it('should switch to the drawer mode when masterMinSize is set to 100%', async () => { - layout.masterMinSize = '100%'; - await nextResize(layout); - expect(layout.hasAttribute('drawer')).to.be.true; - }); - - it('should not overflow in the drawer mode when detailMinSize is set', async () => { - layout.masterSize = '500px'; - layout.detailMinSize = '500px'; - - await nextResize(layout); - - // Resize so that min size is bigger than layout size. - await setViewport({ width: 480, height }); - await nextResize(layout); - - expect(layout.hasAttribute('drawer')).to.be.true; - expect(getComputedStyle(detail).width).to.equal(`${layout.offsetWidth}px`); - expect(getComputedStyle(detail).maxWidth).to.equal('100%'); - }); - - it('should not overflow in the drawer mode when masterMinSize is set', async () => { - layout.masterMinSize = '500px'; - await nextResize(layout); - - // Resize so that min size is bigger than layout size. - await setViewport({ width: 480, height }); - await nextResize(layout); - - expect(layout.hasAttribute('drawer')).to.be.true; - expect(getComputedStyle(master).width).to.equal(`${layout.offsetWidth}px`); - expect(getComputedStyle(detail).maxWidth).to.equal('100%'); - }); - - it('should update drawer mode when adding and removing details', async () => { - // Start without details - detailContent.remove(); - await nextRender(); - - // Shrink viewport - layout.detailSize = '300px'; - await setViewport({ width: 500, height }); - await nextResize(layout); - - expect(layout.hasAttribute('drawer')).to.be.false; - - // Add details - layout.appendChild(detailContent); - await nextRender(); - - expect(layout.hasAttribute('drawer')).to.be.true; - expect(getComputedStyle(detailWrapper).position).to.equal('absolute'); - expect(getComputedStyle(detail).width).to.equal('300px'); - - // Remove details - detailContent.remove(); - await nextRender(); - - expect(layout.hasAttribute('drawer')).to.be.false; - }); - - it('should enforce the drawer mode when forceOverlay is set to true', async () => { - layout.forceOverlay = true; - await nextRender(); - expect(layout.hasAttribute('drawer')).to.be.true; - - layout.forceOverlay = false; - await nextRender(); - expect(layout.hasAttribute('drawer')).to.be.false; - }); - - it('should preserve the drawer mode with forceOverlay after removing details', async () => { - layout.forceOverlay = true; - await nextRender(); - - detailContent.remove(); - await nextRender(); - - expect(layout.hasAttribute('drawer')).to.be.true; - - layout.appendChild(detailContent); - await nextRender(); - - expect(layout.hasAttribute('drawer')).to.be.true; - }); - - it('should focus detail content when adding details in the drawer mode', async () => { - // Start without details - detailContent.remove(); - await nextRender(); - - layout.forceOverlay = true; - - // Add details - layout.appendChild(detailContent); - await nextRender(); - - const input = detailContent.shadowRoot.querySelector('input'); - expect(detailContent.shadowRoot.activeElement).to.equal(input); - }); - - it('should immediately switch to drawer mode when wide LitElement detail is added at narrow width', async () => { - layout = fixtureSync(` - -
-
-
- `); - await nextRender(); - - await setViewport({ width: 335, height }); - await nextResize(layout); - - class CustomDetailsElement extends LitElement { - createRenderRoot() { - return this; - } - - render() { - return html` `; - } - } - customElements.define('custom-details-element', CustomDetailsElement); - - const detail = document.createElement('custom-details-element'); - await layout._setDetail(detail); - - expect(layout.hasAttribute('drawer')).to.be.true; - }); - }); - - describe('vertical', () => { - beforeEach(async () => { - layout.orientation = 'vertical'; - layout.style.maxHeight = '500px'; - layout.parentElement.style.height = '100%'; - await nextResize(layout); - }); - - it('should switch to the drawer mode when there is not enough space for both areas', async () => { - // Use the threshold at which the drawer mode is on by default. - await setViewport({ width: 500, height: 400 }); - await nextResize(layout); - - expect(layout.hasAttribute('drawer')).to.be.true; - expect(getComputedStyle(detailWrapper).position).to.equal('absolute'); - }); - - it('should set detail area height in drawer mode when detailSize is set', async () => { - // Use the threshold at which the drawer mode isn't on by default, - // but will be on after setting fixed size on the detail area. - await setViewport({ width: 700, height: 600 }); - await nextResize(layout); - - expect(layout.hasAttribute('drawer')).to.be.false; - - layout.detailSize = '250px'; - await nextResize(layout); - - expect(layout.hasAttribute('drawer')).to.be.true; - expect(getComputedStyle(detailWrapper).position).to.equal('absolute'); - expect(getComputedStyle(detail).height).to.equal('250px'); - - layout.detailSize = ''; - await nextResize(layout); - expect(layout.hasAttribute('drawer')).to.be.false; - }); - - it('should set detail area height in drawer mode when detailMinSize is set', async () => { - // Use the threshold at which the drawer mode isn't on by default, - // but will be on after setting min size on the detail area. - await setViewport({ width: 700, height: 600 }); - await nextResize(layout); - - expect(layout.hasAttribute('drawer')).to.be.false; - - layout.detailMinSize = '250px'; - await nextResize(layout); - - expect(layout.hasAttribute('drawer')).to.be.true; - expect(getComputedStyle(detailWrapper).position).to.equal('absolute'); - expect(getComputedStyle(detail).height).to.equal('250px'); - - layout.detailMinSize = ''; - await nextResize(layout); - expect(layout.hasAttribute('drawer')).to.be.false; - }); - - it('should switch to the drawer mode when masterSize is set', async () => { - // Use the threshold at which the drawer mode isn't on by default, - // but will be on after setting fixed size on the master area. - await setViewport({ width: 700, height: 600 }); - await nextResize(layout); - - expect(layout.hasAttribute('drawer')).to.be.false; - - layout.masterSize = '450px'; - await nextResize(layout); - - expect(layout.hasAttribute('drawer')).to.be.true; - - layout.masterSize = ''; - await nextResize(layout); - expect(layout.hasAttribute('drawer')).to.be.false; - }); - - it('should switch to the drawer mode when masterMinSize is set', async () => { - // Use the threshold at which the drawer mode isn't on by default, - // but will be on after setting min size on the master area. - await setViewport({ width: 700, height: 600 }); - await nextResize(layout); - - expect(layout.hasAttribute('drawer')).to.be.false; - - layout.masterMinSize = '450px'; - await nextResize(layout); - - expect(layout.hasAttribute('drawer')).to.be.true; - - layout.masterMinSize = ''; - await nextResize(layout); - expect(layout.hasAttribute('drawer')).to.be.false; - }); - - it('should update switch to the drawer mode when both sizes are set with border', async () => { - // Add border to the detail area in the drawer mode. - fixtureSync(` - - `); - - await setViewport({ width: 800, height: 490 }); - await nextResize(layout); - - expect(layout.hasAttribute('drawer')).to.be.false; - - layout.masterSize = '250px'; - layout.detailMinSize = '250px'; - await nextResize(layout); - - expect(layout.hasAttribute('drawer')).to.be.true; - - await setViewport({ width: 800, height: 600 }); - await nextResize(layout); - - expect(layout.hasAttribute('drawer')).to.be.false; - }); - }); - - describe('containment', () => { - before(() => { - // Apply padding to body to test viewport containment. - document.body.style.padding = '20px'; - }); - - after(() => { - document.body.style.padding = ''; - }); - - describe('horizontal orientation', () => { - beforeEach(async () => { - // Use the threshold at which the drawer mode is on by default. - await setViewport({ width: 350, height }); - await nextResize(layout); - - expect(layout.hasAttribute('drawer')).to.be.true; - }); - - it('should contain drawer to layout by default', () => { - const layoutBounds = layout.getBoundingClientRect(); - const detailBounds = detail.getBoundingClientRect(); - - expect(getComputedStyle(detailWrapper).position).to.equal('absolute'); - expect(detailBounds.top).to.equal(layoutBounds.top); - expect(detailBounds.bottom).to.equal(layoutBounds.bottom); - expect(detailBounds.right).to.equal(layoutBounds.right); - }); - - it('should contain drawer to viewport when configured', async () => { - layout.containment = 'viewport'; - await nextRender(); - - const detailBounds = detail.getBoundingClientRect(); - const windowBounds = document.documentElement.getBoundingClientRect(); - - expect(getComputedStyle(detailWrapper).position).to.equal('fixed'); - expect(detailBounds.top).to.equal(windowBounds.top); - expect(detailBounds.bottom).to.equal(windowBounds.bottom); - expect(detailBounds.right).to.equal(windowBounds.right); - }); - }); - - describe('vertical orientation', () => { - beforeEach(async () => { - layout.orientation = 'vertical'; - layout.style.maxHeight = '500px'; - layout.parentElement.style.height = '100%'; - - // Use the threshold at which the drawer mode is on by default. - await setViewport({ width: 500, height: 400 }); - await nextResize(layout); - - expect(layout.hasAttribute('drawer')).to.be.true; - }); - - it('should contain overlay to layout by default', () => { - const layoutBounds = layout.getBoundingClientRect(); - const detailBounds = detail.getBoundingClientRect(); - - expect(getComputedStyle(detailWrapper).position).to.equal('absolute'); - expect(detailBounds.left).to.equal(layoutBounds.left); - expect(detailBounds.right).to.equal(layoutBounds.right); - expect(detailBounds.bottom).to.equal(layoutBounds.bottom); - }); - - it('should contain overlay to viewport when configured', async () => { - layout.containment = 'viewport'; - await nextRender(); - - const detailBounds = detail.getBoundingClientRect(); - const windowBounds = document.documentElement.getBoundingClientRect(); - - expect(getComputedStyle(detailWrapper).position).to.equal('fixed'); - expect(detailBounds.left).to.equal(windowBounds.left); - expect(detailBounds.right).to.equal(windowBounds.right); - expect(detailBounds.bottom).to.equal(windowBounds.bottom); - }); - }); - }); -}); diff --git a/packages/master-detail-layout/test/events.test.js b/packages/master-detail-layout/test/events.test.js index 97028d6378e..70a7d0231d7 100644 --- a/packages/master-detail-layout/test/events.test.js +++ b/packages/master-detail-layout/test/events.test.js @@ -1,10 +1,11 @@ import { expect } from '@vaadin/chai-plugins'; import { resetMouse, sendKeys, sendMouse } from '@vaadin/test-runner-commands'; -import { fixtureSync, nextRender } from '@vaadin/testing-helpers'; +import { fixtureSync } from '@vaadin/testing-helpers'; import sinon from 'sinon'; import '../src/vaadin-master-detail-layout.js'; import './helpers/master-content.js'; import './helpers/detail-content.js'; +import { onceResized } from './helpers.js'; window.Vaadin ||= {}; window.Vaadin.featureFlags ||= {}; @@ -16,20 +17,18 @@ describe('events', () => { describe('default', () => { beforeEach(async () => { layout = fixtureSync(` - + `); - await nextRender(); + await onceResized(layout); }); describe('backdrop click', () => { let spy; beforeEach(() => { - layout.forceOverlay = true; - spy = sinon.spy(); layout.addEventListener('backdrop-click', spy); }); @@ -38,14 +37,14 @@ describe('events', () => { await resetMouse(); }); - it('should fire backdrop-click event on backdrop click in drawer mode', async () => { + it('should fire backdrop-click event on backdrop click in overlay mode', async () => { const backdrop = layout.shadowRoot.querySelector('[part="backdrop"]'); const bounds = backdrop.getBoundingClientRect(); await sendMouse({ type: 'click', position: [bounds.x + 10, bounds.y + 10] }); expect(spy).to.be.calledOnce; }); - it('should not fire backdrop-click event on detail content click in drawer mode', async () => { + it('should not fire backdrop-click event on detail content click in overlay mode', async () => { const detail = layout.querySelector('[slot="detail"]'); const bounds = detail.getBoundingClientRect(); await sendMouse({ type: 'click', position: [bounds.x + 10, bounds.y + 10] }); @@ -62,17 +61,8 @@ describe('events', () => { }); it('should fire detail-escape-press event on pressing Escape in split mode', async () => { - const spy = sinon.spy(); - layout.addEventListener('detail-escape-press', spy); - - focusable.focus(); - await sendKeys({ press: 'Escape' }); - - expect(spy).to.be.calledOnce; - }); - - it('should fire detail-escape-press event on pressing Escape in drawer mode', async () => { - layout.forceOverlay = true; + layout.style.width = '800px'; + await onceResized(layout); const spy = sinon.spy(); layout.addEventListener('detail-escape-press', spy); @@ -83,10 +73,7 @@ describe('events', () => { expect(spy).to.be.calledOnce; }); - it('should fire detail-escape-press event on pressing Escape in stack mode', async () => { - layout.forceOverlay = true; - layout.stackOverlay = true; - + it('should fire detail-escape-press event on pressing Escape in overlay mode', async () => { const spy = sinon.spy(); layout.addEventListener('detail-escape-press', spy); @@ -133,7 +120,7 @@ describe('events', () => { `); - await nextRender(); + await onceResized(layout); nested = layout.querySelector('[slot="detail"]'); }); diff --git a/packages/master-detail-layout/test/helpers.js b/packages/master-detail-layout/test/helpers.js new file mode 100644 index 00000000000..2cd83f4f61a --- /dev/null +++ b/packages/master-detail-layout/test/helpers.js @@ -0,0 +1,11 @@ +import { nextRender, nextResize } from '@vaadin/testing-helpers'; + +/** + * Waits for a ResizeObserver cycle and the subsequent rAF write phase + * in `__onResize()` to complete. Use after any change that should trigger + * overflow recalculation (host resize, property change, DOM change). + */ +export async function onceResized(layout) { + await nextResize(layout); + await nextRender(); +} diff --git a/packages/master-detail-layout/test/master-detail-layout.test.js b/packages/master-detail-layout/test/master-detail-layout.test.js index 4308107f96f..295c637e107 100644 --- a/packages/master-detail-layout/test/master-detail-layout.test.js +++ b/packages/master-detail-layout/test/master-detail-layout.test.js @@ -1,181 +1,156 @@ import { expect } from '@vaadin/chai-plugins'; -import { setViewport } from '@vaadin/test-runner-commands'; -import { fixtureSync, nextRender, nextResize } from '@vaadin/testing-helpers'; +import { fixtureSync } from '@vaadin/testing-helpers'; +import sinon from 'sinon'; import '../src/vaadin-master-detail-layout.js'; -import './helpers/master-content.js'; -import './helpers/detail-content.js'; +import { onceResized } from './helpers.js'; window.Vaadin ||= {}; window.Vaadin.featureFlags ||= {}; window.Vaadin.featureFlags.masterDetailLayoutComponent = true; describe('vaadin-master-detail-layout', () => { - let layout, master, detail, detailWrapper, detailContent; - let width, height; - - before(() => { - width = window.innerWidth; - height = window.innerHeight; - }); + let layout, master, detail; beforeEach(async () => { layout = fixtureSync(` - - +
Master
+
Detail
`); - await nextRender(); + await onceResized(layout); master = layout.shadowRoot.querySelector('[part="master"]'); detail = layout.shadowRoot.querySelector('[part="detail"]'); - detailWrapper = detail.parentElement; - detailContent = layout.querySelector('[slot="detail"]'); - }); - - afterEach(async () => { - await setViewport({ width, height }); }); describe('custom element definition', () => { - let tagName; - - beforeEach(() => { - tagName = layout.tagName.toLowerCase(); + it('should be defined in custom element registry', () => { + expect(customElements.get('vaadin-master-detail-layout')).to.be.ok; }); - it('should be defined in custom element registry', () => { - expect(customElements.get(tagName)).to.be.ok; + it('should have a valid localName', () => { + expect(layout.localName).to.equal('vaadin-master-detail-layout'); }); - it('should have a valid static "is" getter', () => { - expect(customElements.get(tagName).is).to.equal(tagName); + it('should have display grid', () => { + expect(getComputedStyle(layout).display).to.equal('grid'); }); }); - describe('default', () => { - it('should set height: 100% on the host element to expand to full height', () => { - layout.parentElement.style.height = '1000px'; - expect(getComputedStyle(master).height).to.equal('1000px'); - expect(getComputedStyle(detail).height).to.equal('1000px'); + describe('detail', () => { + it('should set has-detail when detail content is provided', () => { + expect(layout.hasAttribute('has-detail')).to.be.true; }); - it('should set display: contents on detail part wrapper with the detail child if provided', () => { - expect(getComputedStyle(detailWrapper).display).to.equal('contents'); + it('should remove has-detail when detail is removed', async () => { + layout.querySelector('[slot="detail"]').remove(); + await onceResized(layout); + expect(layout.hasAttribute('has-detail')).to.be.false; }); - it('should set display: none on detail part wrapper after the detail child is removed', async () => { - detailContent.remove(); - await nextRender(); - expect(getComputedStyle(detailWrapper).display).to.equal('none'); - }); - }); + it('should set has-detail when detail becomes visible', async () => { + const detailContent = layout.querySelector('[slot="detail"]'); + detailContent.hidden = true; + await onceResized(layout); + expect(layout.hasAttribute('has-detail')).to.be.false; - describe('size properties', () => { - describe('default', () => { - it('should set flex-basis to 50% on the master and detail by default', () => { - expect(getComputedStyle(master).flexBasis).to.equal('50%'); - expect(getComputedStyle(detail).flexBasis).to.equal('50%'); - }); - - it('should set flex-grow to 1 on the master and detail by default', () => { - expect(getComputedStyle(master).flexGrow).to.equal('1'); - expect(getComputedStyle(detail).flexGrow).to.equal('1'); - }); - - it('should set fixed width on the master area when masterSize is set', () => { - layout.masterSize = '300px'; - expect(getComputedStyle(master).width).to.equal('300px'); - expect(getComputedStyle(master).flexBasis).to.equal('auto'); - expect(getComputedStyle(master).flexGrow).to.equal('0'); - expect(getComputedStyle(master).flexShrink).to.equal('0'); - }); - - it('should set fixed width on the detail area when detailSize is set', () => { - layout.detailSize = '300px'; - expect(getComputedStyle(detail).width).to.equal('300px'); - expect(getComputedStyle(detail).flexBasis).to.equal('auto'); - expect(getComputedStyle(detail).flexGrow).to.equal('0'); - expect(getComputedStyle(detail).flexShrink).to.equal('0'); - }); - - it('should use size as flex-basis when both masterSize and detailSize are set', () => { - layout.masterSize = '300px'; - layout.detailSize = '300px'; - expect(getComputedStyle(master).flexBasis).to.equal('300px'); - expect(getComputedStyle(master).flexGrow).to.equal('1'); - expect(getComputedStyle(detail).flexBasis).to.equal('300px'); - expect(getComputedStyle(detail).flexGrow).to.equal('1'); - }); - - it('should use masterMinSize as min-width', () => { - layout.masterMinSize = '300px'; - expect(getComputedStyle(master).minWidth).to.equal('min(100%, 300px)'); - }); + detailContent.hidden = false; + await onceResized(layout); + expect(layout.hasAttribute('has-detail')).to.be.true; + }); - it('should use detailMinSize as min-width', () => { - layout.detailMinSize = '300px'; - expect(getComputedStyle(detail).minWidth).to.equal('min(100%, 300px)'); - }); + it('should expand master to fill the layout when detail is removed', async () => { + layout.masterSize = '200px'; + layout.detailSize = '200px'; + layout.querySelector('[slot="detail"]').remove(); + await onceResized(layout); + expect(master.offsetWidth).to.equal(layout.offsetWidth); + }); - it('should not overflow in split mode when masterSize is set', async () => { - layout.masterSize = '500px'; - detail.remove(); - await nextResize(layout); + it('should expand master to fill the layout when detail is removed with expand detail', async () => { + layout.expand = 'detail'; + layout.masterSize = '200px'; + layout.detailSize = '200px'; + layout.querySelector('[slot="detail"]').remove(); + await onceResized(layout); + expect(master.offsetWidth).to.equal(layout.offsetWidth); + }); + }); - // Resize so that size is bigger than layout size. - await setViewport({ width: 480, height }); - await nextResize(layout); + describe('expand', () => { + it('should be set to both by default', () => { + expect(layout.expand).to.equal('both'); + }); - expect(layout.hasAttribute('drawer')).to.be.false; - expect(layout.offsetWidth).to.equal(480); - expect(master.offsetWidth).to.equal(layout.offsetWidth); - }); + it('should reflect expand property to attribute', () => { + layout.expand = 'master'; + expect(layout.getAttribute('expand')).to.equal('master'); + }); + }); - it('should not overflow in split mode when masterMinSize is set', async () => { - layout.masterMinSize = '500px'; - detail.remove(); - await nextResize(layout); + describe('resize observer', () => { + let onResizeSpy; - // Resize so that min size is bigger than layout size. - await setViewport({ width: 480, height }); - await nextResize(layout); + beforeEach(() => { + onResizeSpy = sinon.spy(layout, '__applyLayoutState'); + }); - expect(layout.hasAttribute('drawer')).to.be.false; - expect(layout.offsetWidth).to.equal(480); - expect(master.offsetWidth).to.equal(layout.offsetWidth); - }); + it('should trigger observer when layout is resized', async () => { + layout.style.height = '100px'; + await onceResized(layout); + expect(onResizeSpy).to.be.called; }); - describe('vertical', () => { - beforeEach(() => { - layout.orientation = 'vertical'; - }); + it('should trigger observer when master part is resized', async () => { + layout.$.master.style.height = '100px'; + await onceResized(layout); + expect(onResizeSpy).to.be.called; + }); - it('should set fixed height on the master area when masterSize is set', () => { - layout.masterSize = '200px'; - expect(getComputedStyle(master).height).to.equal('200px'); - expect(getComputedStyle(master).flexBasis).to.equal('auto'); - expect(getComputedStyle(master).flexGrow).to.equal('0'); - expect(getComputedStyle(master).flexShrink).to.equal('0'); - }); + it('should trigger observer when detail part is resized', async () => { + layout.$.detail.style.height = '100px'; + await onceResized(layout); + expect(onResizeSpy).to.be.called; + }); - it('should set fixed height on the detail area when detailSize is set', () => { - layout.detailSize = '200px'; - expect(getComputedStyle(detail).height).to.equal('200px'); - expect(getComputedStyle(detail).flexBasis).to.equal('auto'); - expect(getComputedStyle(detail).flexGrow).to.equal('0'); - expect(getComputedStyle(detail).flexShrink).to.equal('0'); - }); + it('should trigger observer when a direct child is resized', async () => { + for (const child of layout.children) { + child.style.height = '100px'; + await onceResized(layout); + expect(onResizeSpy).to.be.called; + onResizeSpy.resetHistory(); + } + }); - it('should use masterMinSize as min-height', () => { - layout.masterMinSize = '200px'; - expect(getComputedStyle(master).minHeight).to.equal('min(100%, 200px)'); + it('should not trigger observer when a nested layout child is resized', async () => { + const nestedLayout = fixtureSync( + ` + +
Nested Master
+
Nested Detail
+
+ `, + ); + layout.appendChild(nestedLayout); + await onceResized(layout); + onResizeSpy.resetHistory(); + + [...nestedLayout.children].forEach((child) => { + child.style.height = '100px'; }); + await onceResized(layout); + expect(onResizeSpy).to.be.not.called; + }); + }); - it('should use detailMinSize as min-height', () => { - layout.detailMinSize = '200px'; - expect(getComputedStyle(detail).minHeight).to.equal('min(100%, 200px)'); - }); + describe('height', () => { + it('should expand to full height of the parent', async () => { + layout.masterSize = '200px'; + layout.detailSize = '200px'; + layout.parentElement.style.height = '500px'; + await onceResized(layout); + expect(parseFloat(getComputedStyle(master).height)).to.equal(500); + expect(parseFloat(getComputedStyle(detail).height)).to.equal(500); }); }); }); diff --git a/packages/master-detail-layout/test/overflow.test.js b/packages/master-detail-layout/test/overflow.test.js new file mode 100644 index 00000000000..0752c5b3dc7 --- /dev/null +++ b/packages/master-detail-layout/test/overflow.test.js @@ -0,0 +1,164 @@ +import { expect } from '@vaadin/chai-plugins'; +import { fixtureSync } from '@vaadin/testing-helpers'; +import '../src/vaadin-master-detail-layout.js'; +import { onceResized } from './helpers.js'; + +window.Vaadin ||= {}; +window.Vaadin.featureFlags ||= {}; +window.Vaadin.featureFlags.masterDetailLayoutComponent = true; + +describe('overflow detection', () => { + describe('horizontal', () => { + let layout; + + beforeEach(async () => { + layout = fixtureSync(` + +
Master
+
Detail
+
+ `); + await onceResized(layout); + }); + + describe('layout resize', () => { + it('should not set overflow when columns fit within the layout', () => { + expect(layout.hasAttribute('overflow')).to.be.false; + }); + + it('should set overflow when layout size is decreased below column minimums', async () => { + layout.style.width = '400px'; + await onceResized(layout); + expect(layout.hasAttribute('overflow')).to.be.true; + }); + + it('should remove overflow when layout size is increased to fit columns', async () => { + layout.style.width = '400px'; + await onceResized(layout); + expect(layout.hasAttribute('overflow')).to.be.true; + + layout.style.width = '800px'; + await onceResized(layout); + expect(layout.hasAttribute('overflow')).to.be.false; + }); + }); + + describe('property changes', () => { + it('should set overflow when masterSize increases beyond available space', async () => { + layout.masterSize = '600px'; + await onceResized(layout); + expect(layout.hasAttribute('overflow')).to.be.true; + }); + + it('should remove overflow when masterSize decreases to fit', async () => { + layout.style.width = '400px'; + await onceResized(layout); + + layout.masterSize = '100px'; + await onceResized(layout); + expect(layout.hasAttribute('overflow')).to.be.false; + }); + + it('should remove overflow when masterSize decreases to fit while preserve-master-width is set', async () => { + layout.style.width = '400px'; + await onceResized(layout); + + layout.masterSize = '50px'; + await onceResized(layout); + expect(layout.hasAttribute('overflow')).to.be.false; + }); + + it('should set overflow when masterSize is set to 100%', async () => { + layout.masterSize = '100%'; + await onceResized(layout); + expect(layout.hasAttribute('overflow')).to.be.true; + }); + + it('should not set overflow when detail is removed', async () => { + layout.style.width = '400px'; + await onceResized(layout); + + layout.querySelector('[slot="detail"]').remove(); + await onceResized(layout); + expect(layout.hasAttribute('overflow')).to.be.false; + }); + }); + }); + + describe('vertical', () => { + let layout; + + beforeEach(async () => { + layout = fixtureSync(` + +
Master
+
Detail
+
+ `); + await onceResized(layout); + }); + + it('should not set overflow when rows fit within the layout', () => { + expect(layout.hasAttribute('overflow')).to.be.false; + }); + + it('should set overflow when layout height is decreased below row minimums', async () => { + layout.style.height = '400px'; + await onceResized(layout); + expect(layout.hasAttribute('overflow')).to.be.true; + }); + + it('should remove overflow when layout height is increased to fit rows', async () => { + layout.style.height = '400px'; + await onceResized(layout); + + layout.style.height = '800px'; + await onceResized(layout); + expect(layout.hasAttribute('overflow')).to.be.false; + }); + }); + + ['horizontal', 'vertical'].forEach((orientation) => { + const sizeProp = orientation === 'vertical' ? 'height' : 'width'; + + describe(`${orientation} - sub-pixel rounding`, () => { + let layout; + + beforeEach(async () => { + layout = fixtureSync(` + +
Master
+
Detail
+
+ `); + await onceResized(layout); + }); + + // Uses getComputedStyle() instead of offsetWidth/offsetHeight to obtain the + // actual fractional host size for overflow comparison. offsetWidth/offsetHeight + // round to integers, which can mask overflow when the fractional host size is + // slightly less than the track sum (e.g. 599.6px rounds up to 600px, matching + // the 300+300 track sum). + it(`should not report false overflow due to sub-pixel rounding`, async () => { + layout.style[sizeProp] = '600.4px'; + await onceResized(layout); + expect(layout.hasAttribute('overflow')).to.be.false; + + layout.style[sizeProp] = '599.6px'; + await onceResized(layout); + expect(layout.hasAttribute('overflow')).to.be.true; + }); + }); + }); +}); diff --git a/packages/master-detail-layout/test/overlay.test.js b/packages/master-detail-layout/test/overlay.test.js new file mode 100644 index 00000000000..878835e1bac --- /dev/null +++ b/packages/master-detail-layout/test/overlay.test.js @@ -0,0 +1,210 @@ +import { expect } from '@vaadin/chai-plugins'; +import { fixtureSync } from '@vaadin/testing-helpers'; +import '../src/vaadin-master-detail-layout.js'; +import { onceResized } from './helpers.js'; + +window.Vaadin ||= {}; +window.Vaadin.featureFlags ||= {}; +window.Vaadin.featureFlags.masterDetailLayoutComponent = true; + +describe('overlay', () => { + describe('default (no overlaySize)', () => { + describe('horizontal', () => { + let layout, detail, backdrop; + + beforeEach(async () => { + layout = fixtureSync(` + +
Master
+
Detail
+
+ `); + await onceResized(layout); + detail = layout.shadowRoot.querySelector('[part="detail"]'); + backdrop = layout.shadowRoot.querySelector('[part="backdrop"]'); + }); + + it('should use absolute positioning and show backdrop', () => { + expect(getComputedStyle(detail).position).to.equal('absolute'); + expect(getComputedStyle(backdrop).display).to.equal('block'); + }); + + it('should set detail width to detailSize', () => { + expect(getComputedStyle(detail).width).to.equal('300px'); + }); + + it('should align detail to the inline end with full block height', () => { + const s = getComputedStyle(detail); + expect(s.insetInlineEnd).to.equal('0px'); + expect(s.insetBlockStart).to.equal('0px'); + expect(s.insetBlockEnd).to.equal('0px'); + }); + + it('should switch to overlay when detail is added to a narrow layout', async () => { + const detailContent = layout.querySelector('[slot="detail"]'); + detailContent.remove(); + await onceResized(layout); + expect(layout.hasAttribute('overflow')).to.be.false; + + layout.appendChild(detailContent); + await onceResized(layout); + expect(layout.hasAttribute('overflow')).to.be.true; + expect(getComputedStyle(detail).position).to.equal('absolute'); + }); + }); + + describe('vertical', () => { + let layout, detail; + + beforeEach(async () => { + layout = fixtureSync(` + +
Master
+
Detail
+
+ `); + await onceResized(layout); + detail = layout.shadowRoot.querySelector('[part="detail"]'); + }); + + it('should set detail height to detailSize', () => { + expect(getComputedStyle(detail).height).to.equal('300px'); + }); + + it('should align detail to the block end with full inline width', () => { + const s = getComputedStyle(detail); + expect(s.insetBlockEnd).to.equal('0px'); + expect(s.insetInlineStart).to.equal('0px'); + expect(s.insetInlineEnd).to.equal('0px'); + }); + }); + }); + + describe('overlaySize 100%', () => { + describe('horizontal', () => { + let layout, detail, backdrop; + + beforeEach(async () => { + layout = fixtureSync(` + +
Master
+
Detail
+
+ `); + await onceResized(layout); + detail = layout.shadowRoot.querySelector('[part="detail"]'); + backdrop = layout.shadowRoot.querySelector('[part="backdrop"]'); + }); + + it('should show backdrop', () => { + expect(getComputedStyle(backdrop).display).to.equal('block'); + }); + + it('should make detail as wide as the layout', () => { + expect(detail.offsetWidth).to.equal(layout.offsetWidth); + }); + + it('should switch back to split mode when layout grows', async () => { + layout.style.width = '800px'; + await onceResized(layout); + expect(layout.hasAttribute('overflow')).to.be.false; + }); + + it('should switch to overlay when detail is added to a narrow layout', async () => { + const detailContent = layout.querySelector('[slot="detail"]'); + detailContent.remove(); + await onceResized(layout); + + layout.appendChild(detailContent); + await onceResized(layout); + expect(layout.hasAttribute('overflow')).to.be.true; + expect(detail.offsetWidth).to.equal(layout.offsetWidth); + }); + }); + + describe('vertical', () => { + let layout, detail; + + beforeEach(async () => { + layout = fixtureSync(` + +
Master
+
Detail
+
+ `); + await onceResized(layout); + detail = layout.shadowRoot.querySelector('[part="detail"]'); + }); + + it('should make detail as tall as the layout', () => { + expect(detail.offsetHeight).to.equal(layout.offsetHeight); + }); + }); + }); + + describe('overlayContainment viewport', () => { + ['horizontal', 'vertical'].forEach((orientation) => { + describe(orientation, () => { + let layout, detail, backdrop; + const isVertical = orientation === 'vertical'; + const sizeStyle = isVertical ? 'height: 400px;' : 'width: 400px;'; + const orientationAttr = isVertical ? 'orientation="vertical"' : ''; + + beforeEach(async () => { + layout = fixtureSync(` + +
Master
+
Detail
+
+ `); + await onceResized(layout); + detail = layout.shadowRoot.querySelector('[part="detail"]'); + backdrop = layout.shadowRoot.querySelector('[part="backdrop"]'); + }); + + it('should use fixed positioning for detail and backdrop', () => { + expect(getComputedStyle(detail).position).to.equal('fixed'); + expect(getComputedStyle(backdrop).position).to.equal('fixed'); + }); + + it(`should set detail ${isVertical ? 'height' : 'width'} to detailSize`, () => { + expect(getComputedStyle(detail)[isVertical ? 'height' : 'width']).to.equal('300px'); + }); + + it('should make backdrop cover the full viewport', () => { + expect(backdrop.offsetWidth).to.equal(window.innerWidth); + expect(backdrop.offsetHeight).to.equal(window.innerHeight); + }); + + it('should make detail cover the full viewport with overlaySize 100%', async () => { + layout.overlaySize = '100%'; + await onceResized(layout); + expect(detail.offsetWidth).to.equal(window.innerWidth); + expect(detail.offsetHeight).to.equal(window.innerHeight); + }); + }); + }); + }); +}); diff --git a/packages/master-detail-layout/test/split-mode.test.js b/packages/master-detail-layout/test/split-mode.test.js new file mode 100644 index 00000000000..5ad4cdc67a0 --- /dev/null +++ b/packages/master-detail-layout/test/split-mode.test.js @@ -0,0 +1,135 @@ +import { expect } from '@vaadin/chai-plugins'; +import { fixtureSync } from '@vaadin/testing-helpers'; +import '../src/vaadin-master-detail-layout.js'; +import { onceResized } from './helpers.js'; + +window.Vaadin ||= {}; +window.Vaadin.featureFlags ||= {}; +window.Vaadin.featureFlags.masterDetailLayoutComponent = true; + +function getPartSizes(layout, dimension) { + const master = layout.shadowRoot.querySelector('[part="master"]'); + const detail = layout.shadowRoot.querySelector('[part="detail"]'); + const prop = dimension === 'height' ? 'offsetHeight' : 'offsetWidth'; + return [master[prop], detail[prop]]; +} + +describe('split mode', () => { + describe('horizontal', () => { + let layout; + + beforeEach(async () => { + layout = fixtureSync(` + +
Master
+
Detail
+
+ `); + await onceResized(layout); + }); + + describe('expand both (default)', () => { + it('should expand both columns equally when both sizes are the same', async () => { + layout.masterSize = '200px'; + layout.detailSize = '200px'; + await onceResized(layout); + const [masterWidth, detailWidth] = getPartSizes(layout, 'width'); + expect(masterWidth).to.equal(300); + expect(detailWidth).to.equal(300); + }); + + it('should use masterSize as minimum and expand both columns', async () => { + layout.masterSize = '300px'; + layout.detailSize = '100px'; + await onceResized(layout); + const [masterWidth, detailWidth] = getPartSizes(layout, 'width'); + expect(masterWidth).to.be.at.least(300); + expect(detailWidth).to.be.at.least(100); + expect(masterWidth + detailWidth).to.equal(600); + }); + + it('should use detailSize as minimum and expand both columns', async () => { + layout.masterSize = '100px'; + layout.detailSize = '300px'; + await onceResized(layout); + const [masterWidth, detailWidth] = getPartSizes(layout, 'width'); + expect(masterWidth).to.be.at.least(100); + expect(detailWidth).to.be.at.least(300); + expect(masterWidth + detailWidth).to.equal(600); + }); + }); + + describe('expand master', () => { + it('should fix detail and expand master to fill the rest', async () => { + layout.expand = 'master'; + layout.masterSize = '100px'; + layout.detailSize = '200px'; + await onceResized(layout); + const [masterWidth, detailWidth] = getPartSizes(layout, 'width'); + expect(masterWidth).to.equal(400); + expect(detailWidth).to.equal(200); + }); + }); + + describe('expand detail', () => { + it('should fix master and expand detail to fill the rest', async () => { + layout.expand = 'detail'; + layout.masterSize = '200px'; + layout.detailSize = '100px'; + await onceResized(layout); + const [masterWidth, detailWidth] = getPartSizes(layout, 'width'); + expect(masterWidth).to.equal(200); + expect(detailWidth).to.equal(400); + }); + }); + }); + + describe('vertical', () => { + let layout; + + beforeEach(async () => { + layout = fixtureSync(` + +
Master
+
Detail
+
+ `); + await onceResized(layout); + }); + + describe('expand both (default)', () => { + it('should expand both rows equally when both sizes are the same', async () => { + layout.masterSize = '200px'; + layout.detailSize = '200px'; + await onceResized(layout); + const [masterHeight, detailHeight] = getPartSizes(layout, 'height'); + expect(masterHeight).to.equal(300); + expect(detailHeight).to.equal(300); + }); + }); + + describe('expand master', () => { + it('should fix detail and expand master to fill the rest', async () => { + layout.expand = 'master'; + layout.masterSize = '100px'; + layout.detailSize = '200px'; + await onceResized(layout); + const [masterHeight, detailHeight] = getPartSizes(layout, 'height'); + expect(masterHeight).to.equal(400); + expect(detailHeight).to.equal(200); + }); + }); + + describe('expand detail', () => { + it('should fix master and expand detail to fill the rest', async () => { + layout.expand = 'detail'; + layout.masterSize = '200px'; + layout.detailSize = '100px'; + await onceResized(layout); + const [masterHeight, detailHeight] = getPartSizes(layout, 'height'); + expect(masterHeight).to.equal(200); + expect(detailHeight).to.equal(400); + }); + }); + }); +}); diff --git a/packages/master-detail-layout/test/stack-mode.test.js b/packages/master-detail-layout/test/stack-mode.test.js deleted file mode 100644 index 1f37354b101..00000000000 --- a/packages/master-detail-layout/test/stack-mode.test.js +++ /dev/null @@ -1,307 +0,0 @@ -import { expect } from '@vaadin/chai-plugins'; -import { setViewport } from '@vaadin/test-runner-commands'; -import { fixtureSync, nextRender, nextResize } from '@vaadin/testing-helpers'; -import '../src/vaadin-master-detail-layout.js'; -import './helpers/master-content.js'; -import './helpers/detail-content.js'; - -window.Vaadin ||= {}; -window.Vaadin.featureFlags ||= {}; -window.Vaadin.featureFlags.masterDetailLayoutComponent = true; - -describe('stack mode', () => { - let layout, master, detail, detailWrapper, detailContent; - - let width, height; - - before(() => { - width = window.innerWidth; - height = window.innerHeight; - }); - - afterEach(async () => { - await setViewport({ width, height }); - }); - - describe('default', () => { - beforeEach(async () => { - layout = fixtureSync(` - - - - - `); - await nextRender(); - master = layout.shadowRoot.querySelector('[part="master"]'); - detail = layout.shadowRoot.querySelector('[part="detail"]'); - detailWrapper = detail.parentElement; - detailContent = layout.querySelector('[slot="detail"]'); - }); - - describe('horizontal orientation', () => { - it('should switch from drawer to the stack mode when the stackOverlay is set', async () => { - // Use the threshold at which the drawer mode is on by default. - await setViewport({ width: 350, height }); - await nextResize(layout); - - layout.stackOverlay = true; - - expect(layout.hasAttribute('drawer')).to.be.false; - expect(layout.hasAttribute('stack')).to.be.true; - expect(getComputedStyle(detailWrapper).position).to.equal('absolute'); - expect(getComputedStyle(detailWrapper).inset).to.equal('0px'); - }); - - it('should clear the stack mode when there is enough space for both areas to fit', async () => { - layout.stackOverlay = true; - await nextRender(); - - await setViewport({ width: 350, height }); - await nextResize(layout); - - await setViewport({ width: 450, height }); - await nextResize(layout); - - expect(layout.hasAttribute('stack')).to.be.false; - }); - - it('should switch to the stack mode when forceOverlay is set to true', async () => { - layout.forceOverlay = true; - layout.stackOverlay = true; - await nextRender(); - - await setViewport({ width: 450, height }); - await nextResize(layout); - - expect(layout.hasAttribute('stack')).to.be.true; - expect(layout.hasAttribute('drawer')).to.be.false; - }); - - it('should not apply min-width to the detail area in the stack mode', async () => { - layout.detailMinSize = '500px'; - layout.stackOverlay = true; - await nextRender(); - - await setViewport({ width: 450, height }); - await nextResize(layout); - - expect(layout.hasAttribute('stack')).to.be.true; - expect(getComputedStyle(detail).width).to.equal('450px'); - }); - - it('should not apply width to the detail area in the stack mode', async () => { - layout.detailSize = '500px'; - layout.stackOverlay = true; - await nextRender(); - - await setViewport({ width: 450, height }); - await nextResize(layout); - - expect(layout.hasAttribute('stack')).to.be.true; - expect(getComputedStyle(detail).width).to.equal('450px'); - }); - - it('should update stack mode when adding and removing details', async () => { - layout.stackOverlay = true; - - // Start without details - detailContent.remove(); - await nextRender(); - - // Shrink viewport - layout.detailMinSize = '300px'; - await setViewport({ width: 500, height }); - await nextResize(layout); - - expect(layout.hasAttribute('stack')).to.be.false; - - // Add details - layout.appendChild(detailContent); - await nextRender(); - - expect(layout.hasAttribute('stack')).to.be.true; - expect(getComputedStyle(detailWrapper).position).to.equal('absolute'); - - // Remove details - detailContent.remove(); - await nextRender(); - - expect(layout.hasAttribute('stack')).to.be.false; - }); - - it('should focus detail content when adding details in the stack mode', async () => { - layout.stackOverlay = true; - - // Start without details - detailContent.remove(); - await nextRender(); - - // Shrink viewport - await setViewport({ width: 350, height }); - await nextResize(layout); - - // Add details - layout.appendChild(detailContent); - await nextRender(); - - const input = detailContent.shadowRoot.querySelector('input'); - expect(detailContent.shadowRoot.activeElement).to.equal(input); - }); - - it('should not overflow in stack mode when masterSize is set', async () => { - layout.stackOverlay = true; - layout.masterSize = '500px'; - await nextResize(layout); - - // Resize so that size is bigger than layout size. - await setViewport({ width: 480, height }); - await nextResize(layout); - - expect(layout.hasAttribute('stack')).to.be.true; - expect(layout.offsetWidth).to.equal(480); - expect(master.offsetWidth).to.equal(layout.offsetWidth); - }); - - it('should not overflow in stack mode when masterMinSize is set', async () => { - layout.stackOverlay = true; - layout.masterMinSize = '500px'; - await nextResize(layout); - - // Resize so that size is bigger than layout size. - await setViewport({ width: 480, height }); - await nextResize(layout); - - expect(layout.hasAttribute('stack')).to.be.true; - expect(layout.offsetWidth).to.equal(480); - expect(master.offsetWidth).to.equal(layout.offsetWidth); - }); - - it('should not overflow in stack mode when detailSize is set', async () => { - layout.stackOverlay = true; - layout.detailSize = '500px'; - await nextRender(); - - // Resize so that min size is bigger than layout size - await setViewport({ width: 480, height }); - await nextResize(layout); - - expect(layout.hasAttribute('stack')).to.be.true; - expect(layout.offsetWidth).to.equal(480); - expect(detail.offsetWidth).to.equal(layout.offsetWidth); - }); - - it('should not overflow in stack mode when detailMinSize is set', async () => { - layout.stackOverlay = true; - layout.detailMinSize = '500px'; - await nextRender(); - - // Resize so that min size is bigger than layout size - await setViewport({ width: 480, height }); - await nextResize(layout); - - expect(layout.hasAttribute('stack')).to.be.true; - expect(layout.offsetWidth).to.equal(480); - expect(detail.offsetWidth).to.equal(layout.offsetWidth); - }); - }); - - describe('vertical orientation', () => { - beforeEach(() => { - layout.orientation = 'vertical'; - layout.style.maxHeight = '500px'; - layout.parentElement.style.height = '100%'; - }); - - it('should switch from drawer to the stack mode when the stackOverlay is set', async () => { - // Use the threshold at which the drawer mode is on by default. - await setViewport({ width: 500, height: 400 }); - await nextResize(layout); - - layout.stackOverlay = true; - - expect(layout.hasAttribute('drawer')).to.be.false; - expect(layout.hasAttribute('stack')).to.be.true; - expect(getComputedStyle(detailWrapper).position).to.equal('absolute'); - expect(getComputedStyle(detailWrapper).inset).to.equal('0px'); - }); - - it('should use fixed position in the stack mode when viewport containment is used', async () => { - layout.containment = 'viewport'; - - // Use the threshold at which the drawer mode is on by default. - await setViewport({ width: 500, height: 400 }); - await nextResize(layout); - - layout.stackOverlay = true; - - expect(layout.hasAttribute('drawer')).to.be.false; - expect(layout.hasAttribute('stack')).to.be.true; - expect(getComputedStyle(detailWrapper).position).to.equal('fixed'); - expect(getComputedStyle(detailWrapper).inset).to.equal('0px'); - }); - - it('should not apply min-height to the detail area in the stack mode', async () => { - layout.stackOverlay = true; - layout.detailMinSize = '500px'; - await nextRender(); - - await setViewport({ width, height: 450 }); - await nextResize(layout); - - expect(layout.hasAttribute('stack')).to.be.true; - expect(getComputedStyle(detail).height).to.equal('450px'); - }); - - it('should not apply height to the detail area in the stack mode', async () => { - layout.stackOverlay = true; - layout.detailSize = '500px'; - await nextRender(); - - await setViewport({ width, height: 450 }); - await nextResize(layout); - - expect(layout.hasAttribute('stack')).to.be.true; - expect(getComputedStyle(detail).height).to.equal('450px'); - }); - }); - }); - - describe('nested', () => { - let layout, nested; - - beforeEach(async () => { - layout = fixtureSync(` - -
Master
- -
Nested master
-
Nested detail
-
-
- `); - await nextRender(); - nested = layout.querySelector('[slot="detail"]'); - }); - - it('should switch to the stack mode when layout has master and detail min size', async () => { - // Split mode - await setViewport({ width: 800, height }); - await nextResize(nested); - - expect(nested.hasAttribute('drawer')).to.be.false; - expect(nested.hasAttribute('stack')).to.be.false; - - // Stack mode - await setViewport({ width: 550, height }); - await nextResize(nested); - - expect(nested.hasAttribute('stack')).to.be.true; - }); - }); -}); diff --git a/packages/master-detail-layout/test/typings/master-detail-layout.types.ts b/packages/master-detail-layout/test/typings/master-detail-layout.types.ts index a3c98091ff0..4acbd1321de 100644 --- a/packages/master-detail-layout/test/typings/master-detail-layout.types.ts +++ b/packages/master-detail-layout/test/typings/master-detail-layout.types.ts @@ -12,12 +12,11 @@ assertType(layout); // Properties assertType(layout.detailSize); -assertType(layout.detailMinSize); assertType(layout.masterSize); -assertType(layout.masterMinSize); +assertType(layout.overlaySize); +assertType<'layout' | 'viewport'>(layout.overlayContainment); +assertType<'master' | 'detail' | 'both'>(layout.expand); assertType<'horizontal' | 'vertical'>(layout.orientation); -assertType(layout.forceOverlay); -assertType(layout.stackOverlay); assertType(layout.noAnimation); // Events diff --git a/packages/master-detail-layout/test/view-transitions.test.js b/packages/master-detail-layout/test/view-transitions.test.js index 06492d6abf0..628f0032d48 100644 --- a/packages/master-detail-layout/test/view-transitions.test.js +++ b/packages/master-detail-layout/test/view-transitions.test.js @@ -168,11 +168,9 @@ describe('View transitions', () => { let runUpdateCallback; let updateCallbackResolved; let finishedPromise; - let detectLayoutModeSpy; beforeEach(() => { startViewTransitionSpy = sinon.spy(); - detectLayoutModeSpy = sinon.spy(layout, '__detectLayoutMode'); document.startViewTransition = (callback) => { updateCallbackResolved = false; startViewTransitionSpy(); @@ -189,10 +187,6 @@ describe('View transitions', () => { }; }); - afterEach(() => { - detectLayoutModeSpy.restore(); - }); - it('should allow starting manual transitions', async () => { const updateCallback = sinon.spy(); @@ -212,8 +206,6 @@ describe('View transitions', () => { // Finish transition await layout._finishTransition(); - expect(detectLayoutModeSpy.calledOnce).to.be.true; - await aTimeout(0); expect(updateCallbackResolved).to.be.true; expect(layout.hasAttribute('transition')).to.be.false; }); diff --git a/packages/master-detail-layout/test/visual/aura/master-detail-layout.test.js b/packages/master-detail-layout/test/visual/aura/master-detail-layout.test.js index 5bc459bf757..ed876504ef0 100644 --- a/packages/master-detail-layout/test/visual/aura/master-detail-layout.test.js +++ b/packages/master-detail-layout/test/visual/aura/master-detail-layout.test.js @@ -1,7 +1,8 @@ -import { fixtureSync, nextRender } from '@vaadin/testing-helpers'; +import { fixtureSync } from '@vaadin/testing-helpers'; import { visualDiff } from '@web/test-runner-visual-regression'; import '@vaadin/aura/aura.css'; import '../../../vaadin-master-detail-layout.js'; +import { onceResized } from '../../helpers.js'; window.Vaadin ||= {}; window.Vaadin.featureFlags ||= {}; @@ -20,20 +21,23 @@ describe('master-detail-layout', () => {
`); mdl = element.querySelector('vaadin-master-detail-layout'); - await nextRender(); + await onceResized(mdl); }); describe('split', () => { it('basic', async () => { + mdl.masterSize = '300px'; + mdl.detailSize = '300px'; + await onceResized(mdl); await visualDiff(element, 'split'); }); }); describe('drawer', () => { - beforeEach(() => { - mdl.masterSize = '600px'; + beforeEach(async () => { + mdl.masterSize = '100%'; mdl.detailSize = '300px'; - mdl.forceOverlay = true; + await onceResized(mdl); }); it('basic', async () => { @@ -41,7 +45,8 @@ describe('master-detail-layout', () => { }); it('viewport', async () => { - mdl.containment = 'viewport'; + mdl.overlayContainment = 'viewport'; + await onceResized(mdl); await visualDiff(document.body, 'drawer-viewport'); }); diff --git a/packages/master-detail-layout/test/visual/base/master-detail-layout.test.js b/packages/master-detail-layout/test/visual/base/master-detail-layout.test.js index da9551ab93e..ad53d74d451 100644 --- a/packages/master-detail-layout/test/visual/base/master-detail-layout.test.js +++ b/packages/master-detail-layout/test/visual/base/master-detail-layout.test.js @@ -1,6 +1,7 @@ -import { fixtureSync, nextRender } from '@vaadin/testing-helpers'; +import { fixtureSync } from '@vaadin/testing-helpers'; import { visualDiff } from '@web/test-runner-visual-regression'; import '../../../src/vaadin-master-detail-layout.js'; +import { onceResized } from '../../helpers.js'; window.Vaadin ||= {}; window.Vaadin.featureFlags ||= {}; @@ -19,11 +20,12 @@ describe('master-detail-layout', () => { `); mdl = element.querySelector('vaadin-master-detail-layout'); - await nextRender(); + await onceResized(mdl); }); it('no detail', async () => { mdl.querySelector('[slot="detail"').remove(); + await onceResized(mdl); await visualDiff(element, `no-detail`); }); @@ -38,6 +40,12 @@ describe('master-detail-layout', () => { }); describe('split', () => { + beforeEach(async () => { + mdl.masterSize = '300px'; + mdl.detailSize = '300px'; + await onceResized(mdl); + }); + it('basic', async () => { await visualDiff(element, `${dir}-split-default`); }); @@ -50,10 +58,10 @@ describe('master-detail-layout', () => { }); describe('overlay', () => { - beforeEach(() => { - mdl.masterSize = '600px'; + beforeEach(async () => { + mdl.masterSize = '100%'; mdl.detailSize = '300px'; - mdl.forceOverlay = true; + await onceResized(mdl); }); it('basic', async () => { @@ -61,7 +69,8 @@ describe('master-detail-layout', () => { }); it('viewport', async () => { - mdl.containment = 'viewport'; + mdl.overlayContainment = 'viewport'; + await onceResized(mdl); await visualDiff(document.body, `${dir}-overlay-viewport`); }); diff --git a/packages/master-detail-layout/test/visual/lumo/master-detail-layout.test.js b/packages/master-detail-layout/test/visual/lumo/master-detail-layout.test.js index 19499e8e07e..fbcd6c6180d 100644 --- a/packages/master-detail-layout/test/visual/lumo/master-detail-layout.test.js +++ b/packages/master-detail-layout/test/visual/lumo/master-detail-layout.test.js @@ -1,9 +1,10 @@ -import { fixtureSync, nextRender } from '@vaadin/testing-helpers'; +import { fixtureSync } from '@vaadin/testing-helpers'; import { visualDiff } from '@web/test-runner-visual-regression'; import '@vaadin/vaadin-lumo-styles/src/global/index.css'; import '@vaadin/vaadin-lumo-styles/src/props/index.css'; import '@vaadin/vaadin-lumo-styles/components/master-detail-layout.css'; import '../../../vaadin-master-detail-layout.js'; +import { onceResized } from '../../helpers.js'; window.Vaadin ||= {}; window.Vaadin.featureFlags ||= {}; @@ -19,7 +20,7 @@ describe('master-detail-layout', () => {
Detail content
`); - await nextRender(); + await onceResized(element); }); ['ltr', 'rtl'].forEach((dir) => { @@ -33,10 +34,10 @@ describe('master-detail-layout', () => { }); describe('overlay', () => { - beforeEach(() => { - element.masterSize = '600px'; + beforeEach(async () => { + element.masterSize = '100%'; element.detailSize = '300px'; - element.forceOverlay = true; + await onceResized(element); }); it('basic', async () => { @@ -44,12 +45,14 @@ describe('master-detail-layout', () => { }); it('viewport', async () => { - element.containment = 'viewport'; + element.overlayContainment = 'viewport'; + await onceResized(element); await visualDiff(document.body, `${dir}-overlay-viewport`); }); it('no detail', async () => { element.querySelector('[slot="detail"').remove(); + await onceResized(element); await visualDiff(document.body, `${dir}-overlay-no-detail`); }); diff --git a/packages/master-detail-layout/test/visual/lumo/screenshots/master-detail-layout/baseline/rtl-overlay-dark.png b/packages/master-detail-layout/test/visual/lumo/screenshots/master-detail-layout/baseline/rtl-overlay-dark.png index 3c2586bad42..a3786c9b510 100644 Binary files a/packages/master-detail-layout/test/visual/lumo/screenshots/master-detail-layout/baseline/rtl-overlay-dark.png and b/packages/master-detail-layout/test/visual/lumo/screenshots/master-detail-layout/baseline/rtl-overlay-dark.png differ