From d0b68d2866fa795b199b003a3b23acb304d3f9c5 Mon Sep 17 00:00:00 2001 From: Radoslav Karaivanov Date: Wed, 17 Jun 2026 14:48:21 +0300 Subject: [PATCH] fix(tile-manager): Safari text selection and maximized tile height Closes #2244 #2029 --- src/components/common/context.ts | 2 + src/components/common/controllers/drag.ts | 24 +++++++ .../tile-manager/tile-manager.spec.ts | 66 +++++++++++++++++++ src/components/tile-manager/tile-manager.ts | 27 ++++++++ src/components/tile-manager/tile.ts | 2 +- 5 files changed, 120 insertions(+), 1 deletion(-) diff --git a/src/components/common/context.ts b/src/components/common/context.ts index dfbdcfdb49..ad31a0ac61 100644 --- a/src/components/common/context.ts +++ b/src/components/common/context.ts @@ -9,6 +9,8 @@ export type TileManagerContext = { instance: IgcTileManagerComponent; /** The internal CSS grid container of the igc-tile-manager. */ grid: Ref; + /** Synchronizes the tile manager with the maximized state of its tiles. */ + setMaximizedState: () => void; }; const carouselContext = createContext( diff --git a/src/components/common/controllers/drag.ts b/src/components/common/controllers/drag.ts index 00eb22b4bc..903bc2e0bb 100644 --- a/src/components/common/controllers/drag.ts +++ b/src/components/common/controllers/drag.ts @@ -347,8 +347,11 @@ class DragController implements ReactiveController { Object.assign(this._element.style, { touchAction: cssValue, userSelect: cssValue, + webkitUserSelect: cssValue, }); + this._toggleTextSelection(enabled); + enabled ? this._element.setPointerCapture(this._id) : this._element.releasePointerCapture(this._id); @@ -363,6 +366,27 @@ class DragController implements ReactiveController { } } + /** + * Toggles text selection for the duration of a drag operation. + * + * @remarks + * Disabling `user-select` only on the dragged element is not enough, since browsers + * (notably Safari) will still create a text selection in whatever elements sit under + * the pointer while dragging. Applying it to the owner document's body prevents that + * across the page for the active drag and is reverted once the operation completes. + */ + private _toggleTextSelection(disabled: boolean): void { + const value = disabled ? 'none' : ''; + const { style } = this._host.ownerDocument.body; + + style.userSelect = value; + style.webkitUserSelect = value; + + if (disabled) { + this._host.ownerDocument.getSelection()?.removeAllRanges(); + } + } + private _updateMatcher(event: PointerEvent) { if (!this._options.matchTarget) { return; diff --git a/src/components/tile-manager/tile-manager.spec.ts b/src/components/tile-manager/tile-manager.spec.ts index cfd9f5d113..f8d1ecad4e 100644 --- a/src/components/tile-manager/tile-manager.spec.ts +++ b/src/components/tile-manager/tile-manager.spec.ts @@ -261,6 +261,72 @@ describe('Tile Manager component', () => { }); }); + describe('Maximize', () => { + beforeEach(async () => { + tileManager = await fixture(createTileManager()); + }); + + it('issue 2029 - preserves grid container height when maximizing the only tile with the maximum row-span', async () => { + const grid = getTileManagerBase(); + const tile = tileManager.tiles[0]; + + // Make the first tile the sole contributor to the tallest row track. + tile.rowSpan = 30; + await elementUpdated(tileManager); + + const initialHeight = grid.offsetHeight; + expect(grid.style.minHeight).to.equal(''); + + tile.maximized = true; + await elementUpdated(tileManager); + + // The grid height is locked so the maximized tile's content is not clipped. + expect(grid.style.minHeight).to.equal(`${initialHeight}px`); + expect(grid.offsetHeight).to.equal(initialHeight); + }); + + it('issue 2029 - releases the locked grid height once no tile is maximized', async () => { + const grid = getTileManagerBase(); + const tile = tileManager.tiles[0]; + + tile.rowSpan = 30; + await elementUpdated(tileManager); + + tile.maximized = true; + await elementUpdated(tileManager); + expect(grid.style.minHeight).to.not.equal(''); + + tile.maximized = false; + await elementUpdated(tileManager); + expect(grid.style.minHeight).to.equal(''); + }); + + it('issue 2029 - keeps the grid height locked while any tile remains maximized', async () => { + const grid = getTileManagerBase(); + const [firstTile, secondTile] = tileManager.tiles; + + firstTile.maximized = true; + await elementUpdated(tileManager); + + const lockedHeight = grid.style.minHeight; + expect(lockedHeight).to.not.equal(''); + + secondTile.maximized = true; + await elementUpdated(tileManager); + + // The lock is retained (and not re-measured) while another tile is still maximized. + expect(grid.style.minHeight).to.equal(lockedHeight); + + firstTile.maximized = false; + await elementUpdated(tileManager); + expect(grid.style.minHeight).to.equal(lockedHeight); + + secondTile.maximized = false; + await elementUpdated(tileManager); + expect(grid.style.minHeight).to.equal(''); + }); + }); + describe('Manual slot assignment', () => { beforeEach(async () => { tileManager = await fixture(html` diff --git a/src/components/tile-manager/tile-manager.ts b/src/components/tile-manager/tile-manager.ts index 979eb7f8cc..cf0cb3e3ce 100644 --- a/src/components/tile-manager/tile-manager.ts +++ b/src/components/tile-manager/tile-manager.ts @@ -78,6 +78,7 @@ export default class IgcTileManagerComponent extends LitElement { return { instance: this, grid: this._grid, + setMaximizedState: () => this._setMaximizedState(), }; } @@ -246,6 +247,32 @@ export default class IgcTileManagerComponent extends LitElement { this._tilesState.assignTiles(); } + /** + * Locks/unlocks the grid container height in response to a tile's maximized state changing. + * + * @remarks + * Maximizing a tile removes it from the grid flow (absolute positioning), so it no longer + * contributes to the grid's intrinsic height. When that tile is the sole contributor to the + * tallest row track, the container would otherwise collapse to the remaining tiles and cut off + * the maximized tile's content. Capturing the current height before the layout change keeps the + * container stable, and it is released once no tile remains maximized. + */ + private _setMaximizedState(): void { + const grid = this._grid.value; + + if (grid) { + if (this.tiles.some((tile) => tile.maximized)) { + if (!grid.style.minHeight) { + grid.style.minHeight = `${grid.offsetHeight}px`; + } + } else { + grid.style.minHeight = ''; + } + } + + this.requestUpdate(); + } + // #endregion // #region Public API diff --git a/src/components/tile-manager/tile.ts b/src/components/tile-manager/tile.ts index 719297a31a..7898e1d52d 100644 --- a/src/components/tile-manager/tile.ts +++ b/src/components/tile-manager/tile.ts @@ -300,7 +300,7 @@ export default class IgcTileComponent extends EventEmitterMixin< this._maximized = value; if (this._tileManagerCtx) { - this._tileManagerCtx.instance.requestUpdate(); + this._tileManagerCtx.setMaximizedState(); } }