From 499078669af2eb69675020973173804164ce6423 Mon Sep 17 00:00:00 2001 From: Sergey Vinogradov Date: Thu, 16 Jul 2026 10:56:00 +0400 Subject: [PATCH 01/13] wip: declarative grid header/footer rendering (squashed) --- .../test/dom/__snapshots__/crud.test.snap.js | 52 +-- .../vaadin-grid-column-auto-width-mixin.js | 6 +- packages/grid/src/vaadin-grid-column-mixin.js | 50 ++- .../src/vaadin-grid-column-rendering-mixin.js | 194 +++++++++++ .../vaadin-grid-column-reordering-mixin.js | 7 +- packages/grid/src/vaadin-grid-mixin.js | 183 +++-------- .../grid/src/vaadin-grid-sort-column-mixin.js | 13 +- packages/grid/test/column-groups.test.js | 6 - .../test/dom/__snapshots__/grid.test.snap.js | 310 ++++++++---------- packages/grid/test/helpers.js | 7 +- .../keyboard-navigation-cell-button.test.js | 2 +- 11 files changed, 441 insertions(+), 389 deletions(-) create mode 100644 packages/grid/src/vaadin-grid-column-rendering-mixin.js diff --git a/packages/crud/test/dom/__snapshots__/crud.test.snap.js b/packages/crud/test/dom/__snapshots__/crud.test.snap.js index 7482110a352..8f9a5d9d32d 100644 --- a/packages/crud/test/dom/__snapshots__/crud.test.snap.js +++ b/packages/crud/test/dom/__snapshots__/crud.test.snap.js @@ -106,7 +106,7 @@ snapshots["vaadin-crud host default"] = - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + John - + 30 - + _recalculateColumnWidths() { // Flush to make sure DOM is up-to-date when measuring the column widths this.__virtualizer.flush(); - [...this.$.header.children, ...this.$.footer.children].forEach((row) => { - if (row.__debounceUpdateHeaderFooterRowVisibility) { - row.__debounceUpdateHeaderFooterRowVisibility.flush(); - } - }); + this.__renderHeaderFooter(); this.__hasHadRenderedRowsForColumnWidthCalculation ||= this._getRenderedRows().length > 0; diff --git a/packages/grid/src/vaadin-grid-column-mixin.js b/packages/grid/src/vaadin-grid-column-mixin.js index 8e8dae3c7b7..703c1488a16 100644 --- a/packages/grid/src/vaadin-grid-column-mixin.js +++ b/packages/grid/src/vaadin-grid-column-mixin.js @@ -7,7 +7,8 @@ import { animationFrame } from '@vaadin/component-base/src/async.js'; import { Debouncer } from '@vaadin/component-base/src/debounce.js'; import { DirMixin } from '@vaadin/component-base/src/dir-mixin.js'; import { get } from '@vaadin/component-base/src/path-utils.js'; -import { updateCellState, updatePart } from './vaadin-grid-helpers.js'; +import { generateUniqueId } from '@vaadin/component-base/src/unique-id-utils.js'; +import { updateCellState } from './vaadin-grid-helpers.js'; export const ColumnBaseMixin = (superClass) => class ColumnBaseMixin extends superClass { @@ -153,6 +154,18 @@ export const ColumnBaseMixin = (superClass) => sync: true, }, + /** + * A stable unique id assigned once per column instance. Used to build + * cell content slot names that stay stable across re-renders, so lit + * can reuse the cell content elements when the column tree changes. + * + * @protected + */ + _id: { + type: Number, + value: () => generateUniqueId(), + }, + /** @private */ _reorderStatus: { type: Boolean, @@ -270,7 +283,7 @@ export const ColumnBaseMixin = (superClass) => '_onRendererOrBindingChanged(_renderer, _cells, _bodyContentHidden, path)', '_onHeaderRendererOrBindingChanged(_headerRenderer, _headerCell, path, header)', '_onFooterRendererOrBindingChanged(_footerRenderer, _footerCell)', - '_resizableChanged(resizable, _headerCell)', + '_resizableChanged(resizable)', '_reorderStatusChanged(_reorderStatus, _headerCell, _footerCell, _cells)', '_hiddenChanged(hidden, _headerCell, _footerCell, _cells)', '_rowHeaderChanged(rowHeader, _cells)', @@ -485,27 +498,12 @@ export const ColumnBaseMixin = (superClass) => } /** @private */ - _resizableChanged(resizable, headerCell) { - if (resizable === undefined || headerCell === undefined) { + _resizableChanged(resizable) { + if (resizable === undefined || this._grid === undefined) { return; } - if (headerCell) { - [headerCell].concat(this._emptyCells).forEach((cell) => { - if (cell) { - const existingHandle = cell.querySelector('[part~="resize-handle"]'); - if (existingHandle) { - cell.removeChild(existingHandle); - } - - if (resizable) { - const handle = document.createElement('div'); - updatePart(handle, 'resize-handle', true); - cell.appendChild(handle); - } - } - }); - } + this._grid.__renderHeaderFooter?.(); } /** @private */ @@ -531,7 +529,7 @@ export const ColumnBaseMixin = (superClass) => if (!!hidden !== !!this._previousHidden && this._grid) { if (hidden === true) { - this._allCells.forEach((cell) => { + this._cells?.forEach((cell) => { if (cell._content.parentNode) { cell._content.parentNode.removeChild(cell._content); } @@ -632,9 +630,8 @@ export const ColumnBaseMixin = (superClass) => } this.__renderCellsContent(headerRenderer, [headerCell]); - if (this._grid && headerCell.parentElement) { - this._grid.__debounceUpdateHeaderFooterRowVisibility(headerCell.parentElement); - } + + this._grid?.__scheduleRenderHeaderFooter(); } /** @protected */ @@ -689,9 +686,8 @@ export const ColumnBaseMixin = (superClass) => } this.__renderCellsContent(footerRenderer, [footerCell]); - if (this._grid && footerCell.parentElement) { - this._grid.__debounceUpdateHeaderFooterRowVisibility(footerCell.parentElement); - } + + this._grid?.__scheduleRenderHeaderFooter(); } /** @protected */ diff --git a/packages/grid/src/vaadin-grid-column-rendering-mixin.js b/packages/grid/src/vaadin-grid-column-rendering-mixin.js new file mode 100644 index 00000000000..44042050811 --- /dev/null +++ b/packages/grid/src/vaadin-grid-column-rendering-mixin.js @@ -0,0 +1,194 @@ +/** + * @license + * Copyright (c) 2016 - 2026 Vaadin Ltd. + * This program is available under Apache License Version 2.0, available at https://vaadin.com/license/ + */ +import { html, nothing, render } from 'lit'; +import { AsyncDirective, directive } from 'lit/async-directive.js'; +import { cache } from 'lit/directives/cache.js'; +import { repeat } from 'lit/directives/repeat.js'; +import { microTask } from '@vaadin/component-base/src/async.js'; +import { Debouncer } from '@vaadin/component-base/src/debounce.js'; + +/** + * A directive that manages the `` element for a cell. + */ +class CellContentDirective extends AsyncDirective { + #cell; + + update(part, [grid, slotName]) { + this.#cell = part.parentNode; + this.#cell._content ??= document.createElement('vaadin-grid-cell-content'); + this.#cell._content.slot = slotName; + + const { isConnected } = this.#cell._content; + if (!isConnected) { + grid.appendChild(this.#cell._content); + } + + return html``; + } + + disconnected() { + this.#cell._content?.remove(); + } +} + +const cellContent = directive(CellContentDirective); + +/** + * A mixin providing rendering of rows based on the column tree. + */ +export const ColumnRenderingMixin = (superClass) => + class ColumnRenderingMixin extends superClass { + /** @private */ + __scheduleRenderHeaderFooter() { + this.__renderHeaderFooterDebouncer = Debouncer.debounce(this.__renderHeaderFooterDebouncer, microTask, () => { + this.__renderHeaderFooter(); + }); + } + + /** @private */ + __renderHeaderFooter() { + this.__renderHeaderFooterDebouncer?.cancel(); + + const sortedColumnTree = (this._columnTree ?? []).map((columns) => { + return columns.toSorted((a, b) => a._order - b._order); + }); + + this.#renderHeader(sortedColumnTree); + this.#renderFooter(sortedColumnTree); + + this._resetKeyboardNavigation(); + this.__a11yUpdateGridSize(this.size, this._columnTree, this.__emptyState); + } + + #renderHeader(columnTree) { + render( + repeat(columnTree, (columns, level) => this.#renderHeaderRow(columns, level)), + this.$.header, + { host: this }, + ); + + this.$.table.toggleAttribute('has-header', !!this.$.header.querySelector('tr:not([hidden])')); + this.__updateHeaderFooterRowParts('header'); + } + + #renderHeaderRow(columns, level) { + const isRowVisible = this.#isHeaderRowVisible(columns, level); + + return html` + + ${repeat( + columns, + (column) => column._id, + (column) => { + // `cache` keeps the cell and its rendered content when the + // column gets hidden, so it can be restored as-is when the + // column is shown again. + if (column.hidden) { + return cache(nothing); + } + + return cache(html` + + ${cellContent(this, `vaadin-grid-header-cell-content-${level}-${column._id}`)} + ${column.resizable ? html`
` : nothing} + + `); + }, + )} + + `; + } + + #renderFooter(columnTree) { + render( + repeat(columnTree.toReversed(), (columns, level) => this.#renderFooterRow(columns, level)), + this.$.footer, + { host: this }, + ); + + this.$.table.toggleAttribute('has-footer', !!this.$.footer.querySelector('tr:not([hidden])')); + this.__updateHeaderFooterRowParts('footer'); + } + + #renderFooterRow(columns, level) { + const isRowVisible = this.#isFooterRowVisible(columns, level); + + return html` + + ${repeat( + columns, + (column) => column._id, + (column) => { + // `cache` keeps the cell and its rendered content when the + // column gets hidden, so it can be restored as-is when the + // column is shown again. + if (column.hidden) { + return cache(nothing); + } + + return cache(html` + + ${cellContent(this, `vaadin-grid-footer-cell-content-${level}-${column._id}`)} + + `); + }, + )} + + `; + } + + #isHeaderRowVisible(columns, level) { + const isColumnRow = level === this._columnTree.length - 1; + + return columns.some((column) => { + const isEmptyCell = !isColumnRow && column.localName !== 'vaadin-grid-column-group'; + if (isEmptyCell || column.hidden) { + return false; + } + + if (column.header === null) { + // The column header is explicilty set to null -> row should be hidden + return false; + } + + return column.headerRenderer || column.path || column.header !== undefined; + }); + } + + #isFooterRowVisible(columns, level) { + // Footer rows are rendered in reverse order, so the column row is the first one + const isColumnRow = level === 0; + + return columns.some((column) => { + const isEmptyCell = !isColumnRow && column.localName !== 'vaadin-grid-column-group'; + if (isEmptyCell || column.hidden) { + return false; + } + + return column.footerRenderer; + }); + } + }; diff --git a/packages/grid/src/vaadin-grid-column-reordering-mixin.js b/packages/grid/src/vaadin-grid-column-reordering-mixin.js index fe04625a44b..cb5a89ad0fa 100644 --- a/packages/grid/src/vaadin-grid-column-reordering-mixin.js +++ b/packages/grid/src/vaadin-grid-column-reordering-mixin.js @@ -205,6 +205,7 @@ export const ColumnReorderingMixin = (superClass) => } } + this.__renderHeaderFooter(); this._updateGhostPosition(e.detail.x, this._touchDevice ? e.detail.y - 50 : e.detail.y); this._lastDragClientX = e.detail.x; } @@ -434,7 +435,7 @@ export const ColumnReorderingMixin = (superClass) => } /** - * Swaps column orders and physically reorders cells in all rows. + * Swaps column orders and reorders cells in all rows. * @param {!GridColumn} column1 * @param {!GridColumn} column2 * @protected @@ -444,8 +445,7 @@ export const ColumnReorderingMixin = (superClass) => [column1._order, column2._order] = [column2._order, column1._order]; const [firstColumn, secondColumn] = column1._order < column2._order ? [column1, column2] : [column2, column1]; - // Reorder cells in all rows (header, footer, body, sizer) - [...this.$.header.children, ...this.$.footer.children, ...this.$.items.children, this.$.sizer].forEach((row) => { + [...this.$.items.children, this.$.sizer].forEach((row) => { const cells = getBodyRowCells(row); const firstColumnCells = cells.filter((cell) => firstColumn.contains(cell._column)); const secondColumnFirstCell = cells.find((cell) => secondColumn.contains(cell._column)); @@ -456,6 +456,7 @@ export const ColumnReorderingMixin = (superClass) => } }); + this.__scheduleRenderHeaderFooter(); this._debounceUpdateFrozenColumn(); this._updateFirstAndLastColumn(); } diff --git a/packages/grid/src/vaadin-grid-mixin.js b/packages/grid/src/vaadin-grid-mixin.js index 96b56be0a60..870b3807151 100644 --- a/packages/grid/src/vaadin-grid-mixin.js +++ b/packages/grid/src/vaadin-grid-mixin.js @@ -4,9 +4,7 @@ * This program is available under Apache License Version 2.0, available at https://vaadin.com/license/ */ import { TabindexMixin } from '@vaadin/a11y-base/src/tabindex-mixin.js'; -import { microTask } from '@vaadin/component-base/src/async.js'; import { isAndroid, isIOS, isSafari, isTouch } from '@vaadin/component-base/src/browser-utils.js'; -import { Debouncer } from '@vaadin/component-base/src/debounce.js'; import { setTouchAction } from '@vaadin/component-base/src/gestures.js'; import { SlotObserver } from '@vaadin/component-base/src/slot-observer.js'; import { TooltipController } from '@vaadin/component-base/src/tooltip-controller.js'; @@ -15,6 +13,7 @@ import { A11yMixin } from './vaadin-grid-a11y-mixin.js'; import { ActiveItemMixin } from './vaadin-grid-active-item-mixin.js'; import { ArrayDataProviderMixin } from './vaadin-grid-array-data-provider-mixin.js'; import { ColumnAutoWidthMixin } from './vaadin-grid-column-auto-width-mixin.js'; +import { ColumnRenderingMixin } from './vaadin-grid-column-rendering-mixin.js'; import { ColumnReorderingMixin } from './vaadin-grid-column-reordering-mixin.js'; import { ColumnResizingMixin } from './vaadin-grid-column-resizing-mixin.js'; import { DataProviderMixin } from './vaadin-grid-data-provider-mixin.js'; @@ -48,17 +47,21 @@ export const GridMixin = (superClass) => ArrayDataProviderMixin( DataProviderMixin( DynamicColumnsMixin( - ActiveItemMixin( - ScrollMixin( - SelectionMixin( - SortMixin( - RowDetailsMixin( - KeyboardNavigationMixin( - A11yMixin( - FilterMixin( - ColumnReorderingMixin( - ColumnResizingMixin( - EventContextMixin(DragAndDropMixin(StylingMixin(TabindexMixin(ResizeMixin(superClass))))), + ColumnRenderingMixin( + ActiveItemMixin( + ScrollMixin( + SelectionMixin( + SortMixin( + RowDetailsMixin( + KeyboardNavigationMixin( + A11yMixin( + FilterMixin( + ColumnReorderingMixin( + ColumnResizingMixin( + EventContextMixin( + DragAndDropMixin(StylingMixin(TabindexMixin(ResizeMixin(superClass)))), + ), + ), ), ), ), @@ -351,7 +354,7 @@ export const GridMixin = (superClass) => updatePart(row, 'row', true); updatePart(row, 'body-row', true); if (this._columnTree) { - this.__initRow(row, this._columnTree[this._columnTree.length - 1], 'body', false, true); + this.__initRow(row, this._columnTree[this._columnTree.length - 1], 'body', true); } rows.push(row); } @@ -444,11 +447,10 @@ export const GridMixin = (superClass) => * @param {!HTMLTableRowElement} row * @param {!Array} columns * @param {?string} section - * @param {boolean} isColumnRow * @param {boolean} noNotify * @private */ - __initRow(row, columns, section = 'body', isColumnRow = false, noNotify = false) { + __initRow(row, columns, section = 'body', noNotify = false) { const contentsFragment = document.createDocumentFragment(); iterateRowCells(row, (cell) => { @@ -516,30 +518,6 @@ export const GridMixin = (superClass) => if (!noNotify) { column._cells = [...column._cells]; } - } else { - // Header & footer - const tagName = section === 'header' ? 'th' : 'td'; - if (isColumnRow || column.localName === 'vaadin-grid-column-group') { - cell = column[`_${section}Cell`]; - if (!cell) { - cell = this._createCell(tagName); - } - cell._column = column; - row.appendChild(cell); - column[`_${section}Cell`] = cell; - } else { - if (!column._emptyCells) { - column._emptyCells = []; - } - cell = column._emptyCells.find((cell) => cell._vacant) || this._createCell(tagName); - cell._column = column; - row.appendChild(cell); - if (column._emptyCells.indexOf(cell) === -1) { - column._emptyCells.push(cell); - } - } - updatePart(cell, 'cell', true); - updatePart(cell, `${section}-cell`, true); } if (!cell._content.parentElement) { @@ -549,10 +527,6 @@ export const GridMixin = (superClass) => cell._column = column; }); - if (section !== 'body') { - this.__debounceUpdateHeaderFooterRowVisibility(row); - } - // Might be empty if only cache was used this.appendChild(contentsFragment); @@ -560,75 +534,6 @@ export const GridMixin = (superClass) => this._updateFirstAndLastColumnForRow(row); } - /** - * @param {HTMLTableRowElement} row - * @protected - */ - __debounceUpdateHeaderFooterRowVisibility(row) { - row.__debounceUpdateHeaderFooterRowVisibility = Debouncer.debounce( - row.__debounceUpdateHeaderFooterRowVisibility, - microTask, - () => this.__updateHeaderFooterRowVisibility(row), - ); - } - - /** - * @param {HTMLTableRowElement} row - * @protected - */ - __updateHeaderFooterRowVisibility(row) { - if (!row) { - return; - } - - const visibleRowCells = Array.from(row.children).filter((cell) => { - const column = cell._column; - if (column._emptyCells && column._emptyCells.indexOf(cell) > -1) { - // The cell is an "empty cell" -> doesn't block hiding the row - return false; - } - if (row.parentElement === this.$.header) { - if (column.headerRenderer) { - // The cell is the header cell of a column that has a header renderer - // -> row should be visible - return true; - } - if (column.header === null) { - // The column header is explicilty set to null -> doesn't block hiding the row - return false; - } - if (column.path || column.header !== undefined) { - // The column has an explicit non-null header or a path that generates a header - // -> row should be visible - return true; - } - } else if (column.footerRenderer) { - // The cell is the footer cell of a column that has a footer renderer - // -> row should be visible - return true; - } - return false; - }); - - if (row.hidden !== !visibleRowCells.length) { - row.hidden = !visibleRowCells.length; - } - - if (row.parentElement === this.$.header) { - this.$.table.toggleAttribute('has-header', this.$.header.querySelector('tr:not([hidden])')); - this.__updateHeaderFooterRowParts('header'); - } - - if (row.parentElement === this.$.footer) { - this.$.table.toggleAttribute('has-footer', this.$.footer.querySelector('tr:not([hidden])')); - this.__updateHeaderFooterRowParts('footer'); - } - - // Make sure the section has a tabbable element - this._resetKeyboardNavigation(); - this.__a11yUpdateGridSize(this.size, this._columnTree, this.__emptyState); - } - /** @private */ __updateVirtualizerElement(row, index) { this._preventScrollerRotatingCellFocus(row, index); @@ -681,43 +586,41 @@ export const GridMixin = (superClass) => */ _renderColumnTree(columnTree) { iterateChildren(this.$.items, (row) => { - this.__initRow(row, columnTree[columnTree.length - 1], 'body', false, true); + this.__initRow(row, columnTree[columnTree.length - 1], 'body', true); this.__updateRow(row); }); - while (this.$.header.children.length < columnTree.length) { - const headerRow = document.createElement('tr'); - headerRow.setAttribute('role', 'row'); - headerRow.setAttribute('tabindex', '-1'); - updatePart(headerRow, 'row', true); - updatePart(headerRow, 'header-row', true); - this.$.header.appendChild(headerRow); - - const footerRow = document.createElement('tr'); - footerRow.setAttribute('role', 'row'); - footerRow.setAttribute('tabindex', '-1'); - updatePart(footerRow, 'row', true); - updatePart(footerRow, 'footer-row', true); - this.$.footer.appendChild(footerRow); - } - while (this.$.header.children.length > columnTree.length) { - this.$.header.removeChild(this.$.header.firstElementChild); - this.$.footer.removeChild(this.$.footer.firstElementChild); - } + // Reset before collecting, so stale cells from a previous render (including + // cells of columns that just became hidden) don't accumulate. + this._columnTree.flat().forEach((column) => { + column._emptyCells = []; + }); + + this.__renderHeaderFooter(); - iterateChildren(this.$.header, (headerRow, index) => { - this.__initRow(headerRow, columnTree[index], 'header', index === columnTree.length - 1); + this.$.header.querySelectorAll('.header-cell').forEach((cell) => { + const column = cell._column; + const isColumnRow = cell.parentElement === this.$.header.lastElementChild; + if (isColumnRow || column.localName === 'vaadin-grid-column-group') { + column._headerCell = cell; + } else { + column._emptyCells.push(cell); + } }); - iterateChildren(this.$.footer, (footerRow, index) => { - this.__initRow(footerRow, columnTree[columnTree.length - 1 - index], 'footer', index === 0); + this.$.footer.querySelectorAll('.footer-cell').forEach((cell) => { + const column = cell._column; + const isColumnRow = cell.parentElement === this.$.footer.firstElementChild; + if (isColumnRow || column.localName === 'vaadin-grid-column-group') { + column._footerCell = cell; + } else { + column._emptyCells.push(cell); + } }); // Sizer rows this.__initRow(this.$.sizer, columnTree[columnTree.length - 1]); - this.__updateHeaderFooterRowParts('header'); - this.__updateHeaderFooterRowParts('footer'); this._resizeHandler(); this._frozenCellsChanged(); this._updateFirstAndLastColumn(); @@ -739,6 +642,8 @@ export const GridMixin = (superClass) => updatePart(cell, `first-${section}-row-cell`, row === visibleRows.at(0)); updatePart(cell, `last-${section}-row-cell`, row === visibleRows.at(-1)); }); + + this._updateFirstAndLastColumnForRow(row); }); } diff --git a/packages/grid/src/vaadin-grid-sort-column-mixin.js b/packages/grid/src/vaadin-grid-sort-column-mixin.js index 52bf4a5c058..5f209e2d5c2 100644 --- a/packages/grid/src/vaadin-grid-sort-column-mixin.js +++ b/packages/grid/src/vaadin-grid-sort-column-mixin.js @@ -46,16 +46,25 @@ export const GridSortColumnMixin = (superClass) => */ _defaultHeaderRenderer(root, _column) { let sorter = root.firstElementChild; - if (!sorter) { + const isNewSorter = !sorter; + if (isNewSorter) { sorter = document.createElement('vaadin-grid-sorter'); sorter.addEventListener('direction-changed', this.__boundOnDirectionChanged); - root.appendChild(sorter); } sorter.path = this.path; sorter.__rendererDirection = this.direction; sorter.direction = this.direction; sorter.textContent = this.__getHeader(this.header, this.path); + + // Append the sorter only after its direction has been set. If the cell + // content is already connected (as with the declarative header rendering), + // appending an unconfigured sorter makes it notify its default `null` + // direction before `__rendererDirection` is set, which would reset the + // column's direction. See __onDirectionChanged. + if (isNewSorter) { + root.appendChild(sorter); + } } /** diff --git a/packages/grid/test/column-groups.test.js b/packages/grid/test/column-groups.test.js index e59e933d3df..275e6900c9b 100644 --- a/packages/grid/test/column-groups.test.js +++ b/packages/grid/test/column-groups.test.js @@ -328,7 +328,6 @@ describe('column groups', () => { group.footerRenderer = attributeRenderer('footer'); }); - sinon.spy(grid, '__updateHeaderFooterRowVisibility'); sinon.spy(grid, '_updateColumnTree'); grid.dataProvider = infiniteDataProvider; flushGrid(grid); @@ -336,11 +335,6 @@ describe('column groups', () => { await nextResize(grid); }); - it('should update header and footer rows visibility once', () => { - // 6 header and footer rows are created - expect(grid.__updateHeaderFooterRowVisibility.callCount).to.equal(6); - }); - it('should update column tree once', () => { expect(grid._updateColumnTree.callCount).to.equal(1); }); diff --git a/packages/grid/test/dom/__snapshots__/grid.test.snap.js b/packages/grid/test/dom/__snapshots__/grid.test.snap.js index 051a5b8e442..46d32f548d8 100644 --- a/packages/grid/test/dom/__snapshots__/grid.test.snap.js +++ b/packages/grid/test/dom/__snapshots__/grid.test.snap.js @@ -7,30 +7,30 @@ snapshots["vaadin-grid basic host default"] = - + First - + Last - + - + - + - + - + Laura - + Arnaud - + Fabien - + Le gall @@ -61,25 +61,25 @@ snapshots["vaadin-grid basic shadow default"] = - + - + @@ -100,25 +100,23 @@ snapshots["vaadin-grid basic shadow default"] = - + - + @@ -145,26 +143,26 @@ snapshots["vaadin-grid basic shadow default"] = aria-selected="false" class="body-cell cell drag-disabled-row-cell drop-disabled-row-cell even-row-cell first-column-cell first-row-cell" first-column="" - id="vaadin-grid-cell-6" + id="vaadin-grid-cell-2" part="cell body-cell first-column-cell first-row-cell even-row-cell drag-disabled-row-cell drop-disabled-row-cell" role="gridcell" style="width: 100px; flex-grow: 1;" tabindex="0" > - + - + @@ -185,26 +183,26 @@ snapshots["vaadin-grid basic shadow default"] = aria-selected="false" class="body-cell cell drag-disabled-row-cell drop-disabled-row-cell first-column-cell last-row-cell odd-row-cell" first-column="" - id="vaadin-grid-cell-8" + id="vaadin-grid-cell-4" part="cell body-cell first-column-cell last-row-cell odd-row-cell drag-disabled-row-cell drop-disabled-row-cell" role="gridcell" style="width: 100px; flex-grow: 1;" tabindex="-1" > - + - + @@ -236,31 +234,28 @@ snapshots["vaadin-grid basic shadow default"] = hidden="" part="row footer-row" role="row" - style="--_grid-horizontal-scroll-position: 0px;" tabindex="-1" > - + - + @@ -306,25 +301,25 @@ snapshots["vaadin-grid basic shadow selected"] = - + - + @@ -345,25 +340,23 @@ snapshots["vaadin-grid basic shadow selected"] = - + - + @@ -391,26 +384,26 @@ snapshots["vaadin-grid basic shadow selected"] = aria-selected="true" class="body-cell cell drag-disabled-row-cell drop-disabled-row-cell even-row-cell first-column-cell first-row-cell selected-row-cell" first-column="" - id="vaadin-grid-cell-6" + id="vaadin-grid-cell-2" part="cell body-cell first-column-cell first-row-cell even-row-cell drag-disabled-row-cell drop-disabled-row-cell selected-row-cell" role="gridcell" style="width: 100px; flex-grow: 1;" tabindex="0" > - + - + @@ -431,26 +424,26 @@ snapshots["vaadin-grid basic shadow selected"] = aria-selected="false" class="body-cell cell drag-disabled-row-cell drop-disabled-row-cell first-column-cell last-row-cell odd-row-cell" first-column="" - id="vaadin-grid-cell-8" + id="vaadin-grid-cell-4" part="cell body-cell first-column-cell last-row-cell odd-row-cell drag-disabled-row-cell drop-disabled-row-cell" role="gridcell" style="width: 100px; flex-grow: 1;" tabindex="-1" > - + - + @@ -482,31 +475,28 @@ snapshots["vaadin-grid basic shadow selected"] = hidden="" part="row footer-row" role="row" - style="--_grid-horizontal-scroll-position: 0px;" tabindex="-1" > - + - + @@ -552,25 +542,25 @@ snapshots["vaadin-grid basic shadow details opened"] = - + - + @@ -591,25 +581,23 @@ snapshots["vaadin-grid basic shadow details opened"] = - + - + @@ -636,26 +624,26 @@ snapshots["vaadin-grid basic shadow details opened"] = aria-selected="false" class="body-cell cell drag-disabled-row-cell drop-disabled-row-cell even-row-cell first-column-cell first-row-cell" first-column="" - id="vaadin-grid-cell-6" + id="vaadin-grid-cell-2" part="cell body-cell first-column-cell first-row-cell even-row-cell drag-disabled-row-cell drop-disabled-row-cell" role="gridcell" style="width: 100px; flex-grow: 1;" tabindex="0" > - + - + @@ -676,26 +664,26 @@ snapshots["vaadin-grid basic shadow details opened"] = aria-selected="false" class="body-cell cell drag-disabled-row-cell drop-disabled-row-cell first-column-cell last-row-cell odd-row-cell" first-column="" - id="vaadin-grid-cell-8" + id="vaadin-grid-cell-4" part="cell body-cell first-column-cell last-row-cell odd-row-cell drag-disabled-row-cell drop-disabled-row-cell" role="gridcell" style="width: 100px; flex-grow: 1;" tabindex="-1" > - + - + @@ -727,31 +715,28 @@ snapshots["vaadin-grid basic shadow details opened"] = hidden="" part="row footer-row" role="row" - style="--_grid-horizontal-scroll-position: 0px;" tabindex="-1" > - + - + @@ -797,14 +782,14 @@ snapshots["vaadin-grid basic shadow hidden column"] = - + @@ -825,14 +810,13 @@ snapshots["vaadin-grid basic shadow hidden column"] = - + @@ -859,14 +843,14 @@ snapshots["vaadin-grid basic shadow hidden column"] = aria-selected="false" class="body-cell cell drag-disabled-row-cell drop-disabled-row-cell even-row-cell first-column-cell first-row-cell last-column-cell" first-column="" - id="vaadin-grid-cell-7" + id="vaadin-grid-cell-3" last-column="" part="cell body-cell last-column-cell first-row-cell even-row-cell drag-disabled-row-cell drop-disabled-row-cell first-column-cell" role="gridcell" style="width: 100px; flex-grow: 1;" tabindex="0" > - + @@ -887,14 +871,14 @@ snapshots["vaadin-grid basic shadow hidden column"] = aria-selected="false" class="body-cell cell drag-disabled-row-cell drop-disabled-row-cell first-column-cell last-column-cell last-row-cell odd-row-cell" first-column="" - id="vaadin-grid-cell-9" + id="vaadin-grid-cell-5" last-column="" part="cell body-cell last-column-cell last-row-cell odd-row-cell drag-disabled-row-cell drop-disabled-row-cell first-column-cell" role="gridcell" style="width: 100px; flex-grow: 1;" tabindex="-1" > - + @@ -926,20 +910,18 @@ snapshots["vaadin-grid basic shadow hidden column"] = hidden="" part="row footer-row" role="row" - style="--_grid-horizontal-scroll-position: 0px;" tabindex="-1" > - + @@ -985,14 +967,14 @@ snapshots["vaadin-grid basic shadow hidden column selected"] = - + @@ -1013,14 +995,13 @@ snapshots["vaadin-grid basic shadow hidden column selected"] = - + @@ -1048,14 +1029,14 @@ snapshots["vaadin-grid basic shadow hidden column selected"] = aria-selected="true" class="body-cell cell drag-disabled-row-cell drop-disabled-row-cell even-row-cell first-column-cell first-row-cell last-column-cell selected-row-cell" first-column="" - id="vaadin-grid-cell-7" + id="vaadin-grid-cell-3" last-column="" part="cell body-cell last-column-cell first-row-cell even-row-cell drag-disabled-row-cell drop-disabled-row-cell selected-row-cell first-column-cell" role="gridcell" style="width: 100px; flex-grow: 1;" tabindex="0" > - + @@ -1076,14 +1057,14 @@ snapshots["vaadin-grid basic shadow hidden column selected"] = aria-selected="false" class="body-cell cell drag-disabled-row-cell drop-disabled-row-cell first-column-cell last-column-cell last-row-cell odd-row-cell" first-column="" - id="vaadin-grid-cell-9" + id="vaadin-grid-cell-5" last-column="" part="cell body-cell last-column-cell last-row-cell odd-row-cell drag-disabled-row-cell drop-disabled-row-cell first-column-cell" role="gridcell" style="width: 100px; flex-grow: 1;" tabindex="-1" > - + @@ -1115,20 +1096,18 @@ snapshots["vaadin-grid basic shadow hidden column selected"] = hidden="" part="row footer-row" role="row" - style="--_grid-horizontal-scroll-position: 0px;" tabindex="-1" > - + @@ -1175,25 +1154,25 @@ snapshots["vaadin-grid basic shadow with footer"] = - + - + @@ -1214,25 +1193,23 @@ snapshots["vaadin-grid basic shadow with footer"] = - + - + @@ -1259,26 +1236,26 @@ snapshots["vaadin-grid basic shadow with footer"] = aria-selected="false" class="body-cell cell drag-disabled-row-cell drop-disabled-row-cell even-row-cell first-column-cell first-row-cell" first-column="" - id="vaadin-grid-cell-6" + id="vaadin-grid-cell-2" part="cell body-cell first-column-cell first-row-cell even-row-cell drag-disabled-row-cell drop-disabled-row-cell" role="gridcell" style="width: 100px; flex-grow: 1;" tabindex="0" > - + - + @@ -1299,26 +1276,26 @@ snapshots["vaadin-grid basic shadow with footer"] = aria-selected="false" class="body-cell cell drag-disabled-row-cell drop-disabled-row-cell first-column-cell last-row-cell odd-row-cell" first-column="" - id="vaadin-grid-cell-8" + id="vaadin-grid-cell-4" part="cell body-cell first-column-cell last-row-cell odd-row-cell drag-disabled-row-cell drop-disabled-row-cell" role="gridcell" style="width: 100px; flex-grow: 1;" tabindex="-1" > - + - + @@ -1349,31 +1326,28 @@ snapshots["vaadin-grid basic shadow with footer"] = class="first-footer-row footer-row last-footer-row row" part="row footer-row first-footer-row last-footer-row" role="row" - style="--_grid-horizontal-scroll-position: 0px;" tabindex="-1" > - + - + @@ -1420,25 +1394,25 @@ snapshots["vaadin-grid column groups default"] = - + - + @@ -1461,21 +1435,20 @@ snapshots["vaadin-grid column groups default"] = class="cell first-column-cell header-cell last-column-cell" colspan="2" first-column="" - id="vaadin-grid-cell-0" last-column="" part="cell header-cell first-column-cell last-column-cell" role="columnheader" style="width: calc(200px); flex-grow: 2;" tabindex="-1" > - + - + - + @@ -1528,26 +1499,26 @@ snapshots["vaadin-grid column groups default"] = aria-selected="false" class="body-cell cell drag-disabled-row-cell drop-disabled-row-cell even-row-cell first-column-cell first-row-cell" first-column="" - id="vaadin-grid-cell-8" + id="vaadin-grid-cell-2" part="cell body-cell first-column-cell first-row-cell even-row-cell drag-disabled-row-cell drop-disabled-row-cell" role="gridcell" style="width: 100px; flex-grow: 1;" tabindex="0" > - + - + @@ -1568,26 +1539,26 @@ snapshots["vaadin-grid column groups default"] = aria-selected="false" class="body-cell cell drag-disabled-row-cell drop-disabled-row-cell first-column-cell last-row-cell odd-row-cell" first-column="" - id="vaadin-grid-cell-10" + id="vaadin-grid-cell-4" part="cell body-cell first-column-cell last-row-cell odd-row-cell drag-disabled-row-cell drop-disabled-row-cell" role="gridcell" style="width: 100px; flex-grow: 1;" tabindex="-1" > - + - + @@ -1624,25 +1595,23 @@ snapshots["vaadin-grid column groups default"] = - + - + @@ -1659,14 +1628,13 @@ snapshots["vaadin-grid column groups default"] = class="cell first-column-cell footer-cell last-column-cell" colspan="2" first-column="" - id="vaadin-grid-cell-5" last-column="" part="cell footer-cell first-column-cell last-column-cell" role="gridcell" style="width: calc(200px); flex-grow: 2;" tabindex="-1" > - + @@ -1706,14 +1674,13 @@ snapshots["vaadin-grid column groups with header"] = class="cell first-column-cell first-header-row-cell header-cell last-column-cell" colspan="2" first-column="" - id="vaadin-grid-cell-0" last-column="" part="cell header-cell first-column-cell last-column-cell first-header-row-cell" role="columnheader" style="width: calc(200px); flex-grow: 2;" tabindex="-1" > - + @@ -1728,25 +1695,23 @@ snapshots["vaadin-grid column groups with header"] = - + - + @@ -1771,25 +1736,23 @@ snapshots["vaadin-grid column groups with footer"] = - + - + @@ -1805,14 +1768,13 @@ snapshots["vaadin-grid column groups with footer"] = class="cell first-column-cell footer-cell last-column-cell last-footer-row-cell" colspan="2" first-column="" - id="vaadin-grid-cell-5" last-column="" part="cell footer-cell first-column-cell last-column-cell last-footer-row-cell" role="gridcell" style="width: calc(200px); flex-grow: 2;" tabindex="-1" > - + diff --git a/packages/grid/test/helpers.js b/packages/grid/test/helpers.js index 061f9b5fcb0..5688fd9acfd 100644 --- a/packages/grid/test/helpers.js +++ b/packages/grid/test/helpers.js @@ -15,14 +15,9 @@ export const flushGrid = (grid) => { grid._debouncerHiddenChanged, grid._debouncerApplyCachedData, grid.__debounceUpdateFrozenColumn, + grid.__renderHeaderFooterDebouncer, ].forEach((debouncer) => debouncer?.flush()); - [...grid.$.header.children, ...grid.$.footer.children].forEach((row) => { - if (row.__debounceUpdateHeaderFooterRowVisibility) { - row.__debounceUpdateHeaderFooterRowVisibility.flush(); - } - }); - grid.__virtualizer.flush(); grid.__preventScrollerRotatingCellFocusDebouncer?.flush(); grid.performUpdate?.(); diff --git a/packages/grid/test/keyboard-navigation-cell-button.test.js b/packages/grid/test/keyboard-navigation-cell-button.test.js index 89418c6a473..68d21422dbf 100644 --- a/packages/grid/test/keyboard-navigation-cell-button.test.js +++ b/packages/grid/test/keyboard-navigation-cell-button.test.js @@ -98,6 +98,6 @@ describe('keyboard navigation - focus button mode', () => { it('should not create a focusable div with role="button" inside the header cell', () => { const headerCell = getHeaderCell(grid, 0); - expect(headerCell.firstChild.localName).to.equal('slot'); + expect(headerCell.firstElementChild.localName).to.equal('slot'); }); }); From 36cef0e1db749f72e33304bb8377a93b927ac128 Mon Sep 17 00:00:00 2001 From: Sergey Vinogradov Date: Thu, 16 Jul 2026 13:13:12 +0400 Subject: [PATCH 02/13] fix: show header row when column has header renderer and null header A null header should only suppress the default header text, not hide a row rendered by headerRenderer. Also extract CellContentDirective into its own module and turn the row visibility checks into pure functions. --- .../src/directives/cell-content-directive.js | 33 +++++ .../src/vaadin-grid-column-rendering-mixin.js | 119 +++++++----------- 2 files changed, 81 insertions(+), 71 deletions(-) create mode 100644 packages/grid/src/directives/cell-content-directive.js diff --git a/packages/grid/src/directives/cell-content-directive.js b/packages/grid/src/directives/cell-content-directive.js new file mode 100644 index 00000000000..7321ad508b4 --- /dev/null +++ b/packages/grid/src/directives/cell-content-directive.js @@ -0,0 +1,33 @@ +/** + * @license + * Copyright (c) 2000 - 2026 Vaadin Ltd. + * This program is available under Apache License Version 2.0, available at https://vaadin.com/license/ + */ +import { html } from 'lit'; +import { AsyncDirective, directive } from 'lit/async-directive.js'; + +/** + * A directive that manages the `` element for a cell. + */ +class CellContentDirective extends AsyncDirective { + #cell; + + update(part, [grid, slotName]) { + this.#cell = part.parentNode; + this.#cell._content ??= document.createElement('vaadin-grid-cell-content'); + this.#cell._content.slot = slotName; + + const { isConnected } = this.#cell._content; + if (!isConnected) { + grid.appendChild(this.#cell._content); + } + + return html``; + } + + disconnected() { + this.#cell._content?.remove(); + } +} + +export const cellContent = directive(CellContentDirective); diff --git a/packages/grid/src/vaadin-grid-column-rendering-mixin.js b/packages/grid/src/vaadin-grid-column-rendering-mixin.js index 44042050811..e6530eb9dc6 100644 --- a/packages/grid/src/vaadin-grid-column-rendering-mixin.js +++ b/packages/grid/src/vaadin-grid-column-rendering-mixin.js @@ -4,37 +4,46 @@ * This program is available under Apache License Version 2.0, available at https://vaadin.com/license/ */ import { html, nothing, render } from 'lit'; -import { AsyncDirective, directive } from 'lit/async-directive.js'; import { cache } from 'lit/directives/cache.js'; import { repeat } from 'lit/directives/repeat.js'; import { microTask } from '@vaadin/component-base/src/async.js'; import { Debouncer } from '@vaadin/component-base/src/debounce.js'; +import { cellContent } from './directives/cell-content-directive.js'; -/** - * A directive that manages the `` element for a cell. - */ -class CellContentDirective extends AsyncDirective { - #cell; +function isEmptyCell(column, level, columnTree) { + const isColumnRow = level === columnTree.length - 1; + return !isColumnRow && column.localName !== 'vaadin-grid-column-group'; +} - update(part, [grid, slotName]) { - this.#cell = part.parentNode; - this.#cell._content ??= document.createElement('vaadin-grid-cell-content'); - this.#cell._content.slot = slotName; +function isHeaderRowVisible(columns, level, columnTree) { + return columns.some((column) => { + if (column.hidden || isEmptyCell(column, level, columnTree)) { + return false; + } - const { isConnected } = this.#cell._content; - if (!isConnected) { - grid.appendChild(this.#cell._content); + if (column.headerRenderer) { + // The column has a header renderer -> row should be visible + return true; } - return html``; - } + if (column.header === null) { + // The column header is explicitly set to null -> doesn't block hiding the row + return false; + } - disconnected() { - this.#cell._content?.remove(); - } + return column.path || column.header !== undefined; + }); } -const cellContent = directive(CellContentDirective); +function isFooterRowVisible(columns, level, columnTree) { + return columns.some((column) => { + if (column.hidden || isEmptyCell(column, level, columnTree)) { + return false; + } + + return column.footerRenderer; + }); +} /** * A mixin providing rendering of rows based on the column tree. @@ -64,21 +73,21 @@ export const ColumnRenderingMixin = (superClass) => } #renderHeader(columnTree) { - render( - repeat(columnTree, (columns, level) => this.#renderHeaderRow(columns, level)), - this.$.header, - { host: this }, - ); + render(columnTree.map(this.#renderHeaderRow), this.$.header, { host: this }); this.$.table.toggleAttribute('has-header', !!this.$.header.querySelector('tr:not([hidden])')); this.__updateHeaderFooterRowParts('header'); } - #renderHeaderRow(columns, level) { - const isRowVisible = this.#isHeaderRowVisible(columns, level); - + #renderHeaderRow = (columns, level, columnTree) => { return html` - + ${repeat( columns, (column) => column._id, @@ -110,24 +119,24 @@ export const ColumnRenderingMixin = (superClass) => )} `; - } + }; #renderFooter(columnTree) { - render( - repeat(columnTree.toReversed(), (columns, level) => this.#renderFooterRow(columns, level)), - this.$.footer, - { host: this }, - ); + render(columnTree.map(this.#renderFooterRow).toReversed(), this.$.footer, { host: this }); this.$.table.toggleAttribute('has-footer', !!this.$.footer.querySelector('tr:not([hidden])')); this.__updateHeaderFooterRowParts('footer'); } - #renderFooterRow(columns, level) { - const isRowVisible = this.#isFooterRowVisible(columns, level); - + #renderFooterRow = (columns, level, columnTree) => { return html` - + ${repeat( columns, (column) => column._id, @@ -158,37 +167,5 @@ export const ColumnRenderingMixin = (superClass) => )} `; - } - - #isHeaderRowVisible(columns, level) { - const isColumnRow = level === this._columnTree.length - 1; - - return columns.some((column) => { - const isEmptyCell = !isColumnRow && column.localName !== 'vaadin-grid-column-group'; - if (isEmptyCell || column.hidden) { - return false; - } - - if (column.header === null) { - // The column header is explicilty set to null -> row should be hidden - return false; - } - - return column.headerRenderer || column.path || column.header !== undefined; - }); - } - - #isFooterRowVisible(columns, level) { - // Footer rows are rendered in reverse order, so the column row is the first one - const isColumnRow = level === 0; - - return columns.some((column) => { - const isEmptyCell = !isColumnRow && column.localName !== 'vaadin-grid-column-group'; - if (isEmptyCell || column.hidden) { - return false; - } - - return column.footerRenderer; - }); - } + }; }; From 75e20019ccdbbe8a1cbe8d59357e42f3afbfb73d Mon Sep 17 00:00:00 2001 From: Sergey Vinogradov Date: Thu, 16 Jul 2026 13:16:57 +0400 Subject: [PATCH 03/13] refactor: flush header/footer debouncer instead of forcing render Calling __renderHeaderFooter() directly re-renders header and footer on every column width recalculation, even when no render is pending. Flushing the debouncer applies only a scheduled render. --- packages/grid/src/vaadin-grid-column-auto-width-mixin.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/grid/src/vaadin-grid-column-auto-width-mixin.js b/packages/grid/src/vaadin-grid-column-auto-width-mixin.js index 6f92f7ef619..4fe473672e8 100644 --- a/packages/grid/src/vaadin-grid-column-auto-width-mixin.js +++ b/packages/grid/src/vaadin-grid-column-auto-width-mixin.js @@ -137,7 +137,7 @@ export const ColumnAutoWidthMixin = (superClass) => _recalculateColumnWidths() { // Flush to make sure DOM is up-to-date when measuring the column widths this.__virtualizer.flush(); - this.__renderHeaderFooter(); + this.__renderHeaderFooterDebouncer?.flush(); this.__hasHadRenderedRowsForColumnWidthCalculation ||= this._getRenderedRows().length > 0; From 024277812fcb94dbe2f0bb7e48d2e72ffdae351a Mon Sep 17 00:00:00 2001 From: Sergey Vinogradov Date: Thu, 16 Jul 2026 13:17:40 +0400 Subject: [PATCH 04/13] refactor: render header/footer on drag only when columns are swapped Avoids redundant re-renders on dragover events that don't change column order. --- packages/grid/src/vaadin-grid-column-reordering-mixin.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/grid/src/vaadin-grid-column-reordering-mixin.js b/packages/grid/src/vaadin-grid-column-reordering-mixin.js index cb5a89ad0fa..43d7082762d 100644 --- a/packages/grid/src/vaadin-grid-column-reordering-mixin.js +++ b/packages/grid/src/vaadin-grid-column-reordering-mixin.js @@ -203,9 +203,10 @@ export const ColumnReorderingMixin = (superClass) => for (let i = startIndex; i !== endIndex; i += direction) { this._swapColumnOrders(this._draggedColumn, levelColumnsInOrder[i + direction]); } + + this.__renderHeaderFooter(); } - this.__renderHeaderFooter(); this._updateGhostPosition(e.detail.x, this._touchDevice ? e.detail.y - 50 : e.detail.y); this._lastDragClientX = e.detail.x; } From 236c302f9b7581a86e18a09f6b8a8a54bf767960 Mon Sep 17 00:00:00 2001 From: Sergey Vinogradov Date: Thu, 16 Jul 2026 13:31:08 +0400 Subject: [PATCH 05/13] refactor: schedule header/footer render on column property changes Column observers rendered header and footer synchronously on every property change. Scheduling coalesces multiple changes into one render. --- packages/grid/src/vaadin-grid-column-mixin.js | 2 +- packages/grid/test/column-groups.test.js | 8 +++++++- packages/grid/test/column-resizing.test.js | 1 + 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/packages/grid/src/vaadin-grid-column-mixin.js b/packages/grid/src/vaadin-grid-column-mixin.js index 703c1488a16..ed0ff11fc98 100644 --- a/packages/grid/src/vaadin-grid-column-mixin.js +++ b/packages/grid/src/vaadin-grid-column-mixin.js @@ -503,7 +503,7 @@ export const ColumnBaseMixin = (superClass) => return; } - this._grid.__renderHeaderFooter?.(); + this._grid.__scheduleRenderHeaderFooter?.(); } /** @private */ diff --git a/packages/grid/test/column-groups.test.js b/packages/grid/test/column-groups.test.js index 275e6900c9b..f176840b5d7 100644 --- a/packages/grid/test/column-groups.test.js +++ b/packages/grid/test/column-groups.test.js @@ -328,6 +328,7 @@ describe('column groups', () => { group.footerRenderer = attributeRenderer('footer'); }); + sinon.spy(grid, '__renderHeaderFooter'); sinon.spy(grid, '_updateColumnTree'); grid.dataProvider = infiniteDataProvider; flushGrid(grid); @@ -335,7 +336,12 @@ describe('column groups', () => { await nextResize(grid); }); - it('should update column tree once', () => { + it('should not update header and footer rows too many times', () => { + // One from _updateColumnTree, another one from column observers + expect(grid.__renderHeaderFooter.callCount).to.equal(2); + }); + + it('should not update column tree too many times', () => { expect(grid._updateColumnTree.callCount).to.equal(1); }); diff --git a/packages/grid/test/column-resizing.test.js b/packages/grid/test/column-resizing.test.js index 1f31518e99e..b087e8c75d6 100644 --- a/packages/grid/test/column-resizing.test.js +++ b/packages/grid/test/column-resizing.test.js @@ -174,6 +174,7 @@ describe('column resizing', () => { it('should fix width for preceding columns', () => { grid._columnTree[0][1].resizable = true; + flushGrid(grid); grid._columnTree[0][0].width = '50%'; grid._columnTree[0][0].flexGrow = 1; From ce63b2d1e712af9bc935622f2cc980103adb0212 Mon Sep 17 00:00:00 2001 From: Sergey Vinogradov Date: Thu, 16 Jul 2026 13:33:08 +0400 Subject: [PATCH 06/13] update snapshot tests --- .../grid/test/dom/__snapshots__/grid.test.snap.js | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/grid/test/dom/__snapshots__/grid.test.snap.js b/packages/grid/test/dom/__snapshots__/grid.test.snap.js index 46d32f548d8..693fb30ed8b 100644 --- a/packages/grid/test/dom/__snapshots__/grid.test.snap.js +++ b/packages/grid/test/dom/__snapshots__/grid.test.snap.js @@ -1600,7 +1600,7 @@ snapshots["vaadin-grid column groups default"] = style="width: 100px; flex-grow: 1;" tabindex="0" > - + - + @@ -1634,7 +1634,7 @@ snapshots["vaadin-grid column groups default"] = style="width: calc(200px); flex-grow: 2;" tabindex="-1" > - + @@ -1741,7 +1741,7 @@ snapshots["vaadin-grid column groups with footer"] = style="width: 100px; flex-grow: 1;" tabindex="0" > - + - + @@ -1774,7 +1774,7 @@ snapshots["vaadin-grid column groups with footer"] = style="width: calc(200px); flex-grow: 2;" tabindex="-1" > - + From 5598fa3cf6b8f624f809dfc2f1249e3293519c4e Mon Sep 17 00:00:00 2001 From: Sergey Vinogradov Date: Thu, 16 Jul 2026 13:35:30 +0400 Subject: [PATCH 07/13] update snapshot tests --- .../crud/test/dom/__snapshots__/crud.test.snap.js | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/crud/test/dom/__snapshots__/crud.test.snap.js b/packages/crud/test/dom/__snapshots__/crud.test.snap.js index 8f9a5d9d32d..8828ceb384a 100644 --- a/packages/crud/test/dom/__snapshots__/crud.test.snap.js +++ b/packages/crud/test/dom/__snapshots__/crud.test.snap.js @@ -188,17 +188,17 @@ snapshots["vaadin-crud host default"] = - + - + - + - + - + - + From c3ec23202c7c5a815dcb0fc697d1699bdb3df0c9 Mon Sep 17 00:00:00 2001 From: Sergey Vinogradov Date: Fri, 17 Jul 2026 16:09:29 +0400 Subject: [PATCH 08/13] use conciser grid.contains check --- packages/grid/src/directives/cell-content-directive.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/grid/src/directives/cell-content-directive.js b/packages/grid/src/directives/cell-content-directive.js index 7321ad508b4..c6b1877dc2a 100644 --- a/packages/grid/src/directives/cell-content-directive.js +++ b/packages/grid/src/directives/cell-content-directive.js @@ -17,8 +17,7 @@ class CellContentDirective extends AsyncDirective { this.#cell._content ??= document.createElement('vaadin-grid-cell-content'); this.#cell._content.slot = slotName; - const { isConnected } = this.#cell._content; - if (!isConnected) { + if (!grid.contains(this.#cell._content)) { grid.appendChild(this.#cell._content); } From c70e46b2e8b78a33b05ac3c45866e179bd415203 Mon Sep 17 00:00:00 2001 From: Sergey Vinogradov Date: Fri, 17 Jul 2026 17:48:17 +0400 Subject: [PATCH 09/13] refactor: transfer only body cell content on column (dis)connect Header and footer cells are re-created by the scheduled header/footer render when columns are attached or detached, so their content no longer needs manual transfer. --- packages/grid/src/vaadin-grid-column-mixin.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/grid/src/vaadin-grid-column-mixin.js b/packages/grid/src/vaadin-grid-column-mixin.js index ed0ff11fc98..508620bb6d6 100644 --- a/packages/grid/src/vaadin-grid-column-mixin.js +++ b/packages/grid/src/vaadin-grid-column-mixin.js @@ -326,7 +326,7 @@ export const ColumnBaseMixin = (superClass) => return; } - this._allCells.forEach((cell) => { + this._cells?.forEach((cell) => { if (!cell._content.parentNode) { this._grid.appendChild(cell._content); } @@ -345,7 +345,7 @@ export const ColumnBaseMixin = (superClass) => return; } - this._allCells.forEach((cell) => { + this._cells?.forEach((cell) => { if (cell._content.parentNode) { cell._content.parentNode.removeChild(cell._content); } From c985f8782d2cfe03e9d985be91a92c7fdb53730f Mon Sep 17 00:00:00 2001 From: Sergey Vinogradov Date: Fri, 17 Jul 2026 19:16:32 +0400 Subject: [PATCH 10/13] update snapshot tests --- .../test/dom/__snapshots__/grid.test.snap.js | 87 ++++++++----------- 1 file changed, 35 insertions(+), 52 deletions(-) diff --git a/packages/grid/test/dom/__snapshots__/grid.test.snap.js b/packages/grid/test/dom/__snapshots__/grid.test.snap.js index 693fb30ed8b..f699d730e82 100644 --- a/packages/grid/test/dom/__snapshots__/grid.test.snap.js +++ b/packages/grid/test/dom/__snapshots__/grid.test.snap.js @@ -1805,14 +1805,14 @@ snapshots["vaadin-grid hidden column group with group header"] = - + @@ -1833,14 +1833,13 @@ snapshots["vaadin-grid hidden column group with group header"] = - + @@ -1850,20 +1849,18 @@ snapshots["vaadin-grid hidden column group with group header"] = hidden="" part="row header-row" role="row" - style="--_grid-horizontal-scroll-position: 0px;" tabindex="-1" > - + @@ -1890,14 +1887,14 @@ snapshots["vaadin-grid hidden column group with group header"] = aria-selected="false" class="body-cell cell drag-disabled-row-cell drop-disabled-row-cell even-row-cell first-column-cell first-row-cell last-column-cell" first-column="" - id="vaadin-grid-cell-5" + id="vaadin-grid-cell-1" last-column="" part="cell body-cell first-column-cell last-column-cell first-row-cell even-row-cell drag-disabled-row-cell drop-disabled-row-cell" role="gridcell" style="width: 100px; flex-grow: 1;" tabindex="0" > - + @@ -1918,14 +1915,14 @@ snapshots["vaadin-grid hidden column group with group header"] = aria-selected="false" class="body-cell cell drag-disabled-row-cell drop-disabled-row-cell first-column-cell last-column-cell last-row-cell odd-row-cell" first-column="" - id="vaadin-grid-cell-6" + id="vaadin-grid-cell-2" last-column="" part="cell body-cell first-column-cell last-column-cell last-row-cell odd-row-cell drag-disabled-row-cell drop-disabled-row-cell" role="gridcell" style="width: 100px; flex-grow: 1;" tabindex="-1" > - + @@ -1957,20 +1954,18 @@ snapshots["vaadin-grid hidden column group with group header"] = hidden="" part="row footer-row" role="row" - style="--_grid-horizontal-scroll-position: 0px;" tabindex="-1" > - + @@ -1985,14 +1980,13 @@ snapshots["vaadin-grid hidden column group with group header"] = - + @@ -2038,14 +2032,14 @@ snapshots["vaadin-grid hidden column group with group and column header"] = - + @@ -2066,14 +2060,13 @@ snapshots["vaadin-grid hidden column group with group and column header"] = - + @@ -2088,14 +2081,13 @@ snapshots["vaadin-grid hidden column group with group and column header"] = - + @@ -2122,14 +2114,14 @@ snapshots["vaadin-grid hidden column group with group and column header"] = aria-selected="false" class="body-cell cell drag-disabled-row-cell drop-disabled-row-cell even-row-cell first-column-cell first-row-cell last-column-cell" first-column="" - id="vaadin-grid-cell-5" + id="vaadin-grid-cell-1" last-column="" part="cell body-cell first-column-cell last-column-cell first-row-cell even-row-cell drag-disabled-row-cell drop-disabled-row-cell" role="gridcell" style="width: 100px; flex-grow: 1;" tabindex="0" > - + @@ -2150,14 +2142,14 @@ snapshots["vaadin-grid hidden column group with group and column header"] = aria-selected="false" class="body-cell cell drag-disabled-row-cell drop-disabled-row-cell first-column-cell last-column-cell last-row-cell odd-row-cell" first-column="" - id="vaadin-grid-cell-6" + id="vaadin-grid-cell-2" last-column="" part="cell body-cell first-column-cell last-column-cell last-row-cell odd-row-cell drag-disabled-row-cell drop-disabled-row-cell" role="gridcell" style="width: 100px; flex-grow: 1;" tabindex="-1" > - + @@ -2189,20 +2181,18 @@ snapshots["vaadin-grid hidden column group with group and column header"] = hidden="" part="row footer-row" role="row" - style="--_grid-horizontal-scroll-position: 0px;" tabindex="-1" > - + @@ -2217,14 +2207,13 @@ snapshots["vaadin-grid hidden column group with group and column header"] = - + @@ -2269,14 +2258,14 @@ snapshots["vaadin-grid hidden column group with group footer"] = - + @@ -2297,14 +2286,13 @@ snapshots["vaadin-grid hidden column group with group footer"] = - + @@ -2314,20 +2302,18 @@ snapshots["vaadin-grid hidden column group with group footer"] = hidden="" part="row header-row" role="row" - style="--_grid-horizontal-scroll-position: 0px;" tabindex="-1" > - + @@ -2354,14 +2340,14 @@ snapshots["vaadin-grid hidden column group with group footer"] = aria-selected="false" class="body-cell cell drag-disabled-row-cell drop-disabled-row-cell even-row-cell first-column-cell first-row-cell last-column-cell" first-column="" - id="vaadin-grid-cell-5" + id="vaadin-grid-cell-1" last-column="" part="cell body-cell first-column-cell last-column-cell first-row-cell even-row-cell drag-disabled-row-cell drop-disabled-row-cell" role="gridcell" style="width: 100px; flex-grow: 1;" tabindex="0" > - + @@ -2382,14 +2368,14 @@ snapshots["vaadin-grid hidden column group with group footer"] = aria-selected="false" class="body-cell cell drag-disabled-row-cell drop-disabled-row-cell first-column-cell last-column-cell last-row-cell odd-row-cell" first-column="" - id="vaadin-grid-cell-6" + id="vaadin-grid-cell-2" last-column="" part="cell body-cell first-column-cell last-column-cell last-row-cell odd-row-cell drag-disabled-row-cell drop-disabled-row-cell" role="gridcell" style="width: 100px; flex-grow: 1;" tabindex="-1" > - + @@ -2421,20 +2407,18 @@ snapshots["vaadin-grid hidden column group with group footer"] = hidden="" part="row footer-row" role="row" - style="--_grid-horizontal-scroll-position: 0px;" tabindex="-1" > - + @@ -2449,14 +2433,13 @@ snapshots["vaadin-grid hidden column group with group footer"] = - + From 2c010228ce26afbab6f89b40495ba6d3a7e37bef Mon Sep 17 00:00:00 2001 From: Sergey Vinogradov Date: Fri, 24 Jul 2026 10:44:51 +0400 Subject: [PATCH 11/13] refactor: assign column _id in constructor Co-Authored-By: Claude Fable 5 --- packages/grid/src/vaadin-grid-column-mixin.js | 25 ++++++++++--------- 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/packages/grid/src/vaadin-grid-column-mixin.js b/packages/grid/src/vaadin-grid-column-mixin.js index 508620bb6d6..4cc0a0740a3 100644 --- a/packages/grid/src/vaadin-grid-column-mixin.js +++ b/packages/grid/src/vaadin-grid-column-mixin.js @@ -154,18 +154,6 @@ export const ColumnBaseMixin = (superClass) => sync: true, }, - /** - * A stable unique id assigned once per column instance. Used to build - * cell content slot names that stay stable across re-renders, so lit - * can reuse the cell content elements when the column tree changes. - * - * @protected - */ - _id: { - type: Number, - value: () => generateUniqueId(), - }, - /** @private */ _reorderStatus: { type: Boolean, @@ -315,6 +303,19 @@ export const ColumnBaseMixin = (superClass) => .filter((cell) => cell); } + constructor() { + super(); + + /** + * A stable unique id assigned once per column instance. Used to build + * cell content slot names that stay stable across re-renders, so lit + * can reuse the cell content elements when the column tree changes. + * + * @protected + */ + this._id = generateUniqueId(); + } + /** @protected */ connectedCallback() { super.connectedCallback(); From 95a10cafbd2e4868a247c1d89fc3433274d85830 Mon Sep 17 00:00:00 2001 From: Sergey Vinogradov Date: Fri, 24 Jul 2026 10:45:23 +0400 Subject: [PATCH 12/13] Update packages/grid/src/directives/cell-content-directive.js Co-authored-by: Serhii Kulykov --- packages/grid/src/directives/cell-content-directive.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/grid/src/directives/cell-content-directive.js b/packages/grid/src/directives/cell-content-directive.js index c6b1877dc2a..618073871cd 100644 --- a/packages/grid/src/directives/cell-content-directive.js +++ b/packages/grid/src/directives/cell-content-directive.js @@ -1,6 +1,6 @@ /** * @license - * Copyright (c) 2000 - 2026 Vaadin Ltd. + * Copyright (c) 2016 - 2026 Vaadin Ltd. * This program is available under Apache License Version 2.0, available at https://vaadin.com/license/ */ import { html } from 'lit'; From 9fd5b2ddcf1e4630a8d7ae2c5535e1b5b8fff347 Mon Sep 17 00:00:00 2001 From: Sergey Vinogradov Date: Fri, 24 Jul 2026 10:56:02 +0400 Subject: [PATCH 13/13] refactor: move header/footer cell collection into rendering mixin Cell references (`_headerCell`, `_footerCell`, `_emptyCells`) must be refreshed on every header/footer render, not only on column tree updates, since the rendering mixin can re-render rows on its own. --- .../src/vaadin-grid-column-rendering-mixin.js | 24 +++++++++++++++++ packages/grid/src/vaadin-grid-mixin.js | 26 ------------------- 2 files changed, 24 insertions(+), 26 deletions(-) diff --git a/packages/grid/src/vaadin-grid-column-rendering-mixin.js b/packages/grid/src/vaadin-grid-column-rendering-mixin.js index e6530eb9dc6..ef23d29f9f7 100644 --- a/packages/grid/src/vaadin-grid-column-rendering-mixin.js +++ b/packages/grid/src/vaadin-grid-column-rendering-mixin.js @@ -65,6 +65,10 @@ export const ColumnRenderingMixin = (superClass) => return columns.toSorted((a, b) => a._order - b._order); }); + sortedColumnTree.flat().forEach((column) => { + column._emptyCells = []; + }); + this.#renderHeader(sortedColumnTree); this.#renderFooter(sortedColumnTree); @@ -77,6 +81,16 @@ export const ColumnRenderingMixin = (superClass) => this.$.table.toggleAttribute('has-header', !!this.$.header.querySelector('tr:not([hidden])')); this.__updateHeaderFooterRowParts('header'); + + this.$.header.querySelectorAll('.header-cell').forEach((cell) => { + const column = cell._column; + const isColumnRow = cell.parentElement === this.$.header.lastElementChild; + if (isColumnRow || column.localName === 'vaadin-grid-column-group') { + column._headerCell = cell; + } else { + column._emptyCells.push(cell); + } + }); } #renderHeaderRow = (columns, level, columnTree) => { @@ -126,6 +140,16 @@ export const ColumnRenderingMixin = (superClass) => this.$.table.toggleAttribute('has-footer', !!this.$.footer.querySelector('tr:not([hidden])')); this.__updateHeaderFooterRowParts('footer'); + + this.$.footer.querySelectorAll('.footer-cell').forEach((cell) => { + const column = cell._column; + const isColumnRow = cell.parentElement === this.$.footer.firstElementChild; + if (isColumnRow || column.localName === 'vaadin-grid-column-group') { + column._footerCell = cell; + } else { + column._emptyCells.push(cell); + } + }); } #renderFooterRow = (columns, level, columnTree) => { diff --git a/packages/grid/src/vaadin-grid-mixin.js b/packages/grid/src/vaadin-grid-mixin.js index 870b3807151..ce65503cbc7 100644 --- a/packages/grid/src/vaadin-grid-mixin.js +++ b/packages/grid/src/vaadin-grid-mixin.js @@ -590,34 +590,8 @@ export const GridMixin = (superClass) => this.__updateRow(row); }); - // Reset before collecting, so stale cells from a previous render (including - // cells of columns that just became hidden) don't accumulate. - this._columnTree.flat().forEach((column) => { - column._emptyCells = []; - }); - this.__renderHeaderFooter(); - this.$.header.querySelectorAll('.header-cell').forEach((cell) => { - const column = cell._column; - const isColumnRow = cell.parentElement === this.$.header.lastElementChild; - if (isColumnRow || column.localName === 'vaadin-grid-column-group') { - column._headerCell = cell; - } else { - column._emptyCells.push(cell); - } - }); - - this.$.footer.querySelectorAll('.footer-cell').forEach((cell) => { - const column = cell._column; - const isColumnRow = cell.parentElement === this.$.footer.firstElementChild; - if (isColumnRow || column.localName === 'vaadin-grid-column-group') { - column._footerCell = cell; - } else { - column._emptyCells.push(cell); - } - }); - // Sizer rows this.__initRow(this.$.sizer, columnTree[columnTree.length - 1]);