diff --git a/packages/grid/src/vaadin-grid-active-item-mixin.js b/packages/grid/src/vaadin-grid-active-item-mixin.js index 607f35b166..2145b4ab7c 100644 --- a/packages/grid/src/vaadin-grid-active-item-mixin.js +++ b/packages/grid/src/vaadin-grid-active-item-mixin.js @@ -83,7 +83,7 @@ export const ActiveItemMixin = (superClass) => const path = e.composedPath(); const cell = path[path.indexOf(this.$.table) - 3]; - if (!cell || cell.getAttribute('part').indexOf('details-cell') > -1) { + if (!cell || cell.getAttribute('part').indexOf('details-cell') > -1 || cell === this.$.emptystatecell) { return; } const cellContent = cell._content; diff --git a/packages/grid/src/vaadin-grid-keyboard-navigation-mixin.js b/packages/grid/src/vaadin-grid-keyboard-navigation-mixin.js index 101f84d051..3a3402df47 100644 --- a/packages/grid/src/vaadin-grid-keyboard-navigation-mixin.js +++ b/packages/grid/src/vaadin-grid-keyboard-navigation-mixin.js @@ -675,7 +675,7 @@ export const KeyboardNavigationMixin = (superClass) => const tabOrder = [ this.$.table, this._headerFocusable, - this._itemsFocusable, + this.__emptyState ? this.$.emptystatecell : this._itemsFocusable, this._footerFocusable, this.$.focusexit, ]; @@ -860,7 +860,7 @@ export const KeyboardNavigationMixin = (superClass) => if (cell) { const context = this.getEventContext(e); this.__pendingBodyCellFocus = this.loading && context.section === 'body'; - if (!this.__pendingBodyCellFocus) { + if (!this.__pendingBodyCellFocus && cell !== this.$.emptystatecell) { // Fire a cell-focus event for the cell cell.dispatchEvent(new CustomEvent('cell-focus', { bubbles: true, composed: true, detail: { context } })); } @@ -907,7 +907,7 @@ export const KeyboardNavigationMixin = (superClass) => * @private */ _detectInteracting(e) { - const isInteracting = e.composedPath().some((el) => el.localName === 'vaadin-grid-cell-content'); + const isInteracting = e.composedPath().some((el) => el.localName === 'slot' && this.shadowRoot.contains(el)); this._setInteracting(isInteracting); this.__updateHorizontalScrollPosition(); } diff --git a/packages/grid/src/vaadin-grid-mixin.js b/packages/grid/src/vaadin-grid-mixin.js index 85d4df21fb..1535a1fe39 100644 --- a/packages/grid/src/vaadin-grid-mixin.js +++ b/packages/grid/src/vaadin-grid-mixin.js @@ -9,6 +9,7 @@ import { animationFrame, microTask } from '@vaadin/component-base/src/async.js'; import { isAndroid, isChrome, isFirefox, isIOS, isSafari, isTouch } from '@vaadin/component-base/src/browser-utils.js'; import { Debouncer } from '@vaadin/component-base/src/debounce.js'; import { getClosestElement } from '@vaadin/component-base/src/dom-utils.js'; +import { SlotObserver } from '@vaadin/component-base/src/slot-observer.js'; import { processTemplates } from '@vaadin/component-base/src/templates.js'; import { TooltipController } from '@vaadin/component-base/src/tooltip-controller.js'; import { Virtualizer } from '@vaadin/component-base/src/virtualizer.js'; @@ -156,6 +157,18 @@ export const GridMixin = (superClass) => type: Boolean, value: true, }, + + /** @private */ + __hasEmptyStateContent: { + type: Boolean, + value: false, + }, + + /** @private */ + __emptyState: { + type: Boolean, + computed: '__computeEmptyState(_flatSize, __hasEmptyStateContent)', + }, }; } @@ -261,6 +274,11 @@ export const GridMixin = (superClass) => this._tooltipController = new TooltipController(this); this.addController(this._tooltipController); this._tooltipController.setManual(true); + + this.__emptyStateContentObserver = new SlotObserver(this.$.emptystateslot, ({ currentNodes }) => { + this.$.emptystatecell._content = currentNodes[0]; + this.__hasEmptyStateContent = !!this.$.emptystatecell._content; + }); } /** @private */ @@ -864,6 +882,11 @@ export const GridMixin = (superClass) => }); } + /** @private */ + __computeEmptyState(flatSize, hasEmptyStateContent) { + return flatSize === 0 && hasEmptyStateContent; + } + /** * @param {!Array} columnTree * @protected diff --git a/packages/grid/src/vaadin-grid-styles.js b/packages/grid/src/vaadin-grid-styles.js index 419491ceab..a23ad3a9eb 100644 --- a/packages/grid/src/vaadin-grid-styles.js +++ b/packages/grid/src/vaadin-grid-styles.js @@ -189,6 +189,32 @@ export const gridStyles = css` overflow: hidden; } + /* Empty state */ + + #scroller:not([empty-state]) #emptystatebody, + #scroller[empty-state] #items { + display: none; + } + + #emptystatebody { + display: flex; + position: sticky; + inset: 0; + flex: 1; + overflow: hidden; + } + + #emptystaterow { + display: flex; + flex: 1; + } + + #emptystatecell { + display: block; + flex: 1; + overflow: auto; + } + /* Reordering styles */ :host([reordering]) [part~='cell'] ::slotted(vaadin-grid-cell-content), :host([reordering]) [part~='resize-handle'], diff --git a/packages/grid/src/vaadin-grid.d.ts b/packages/grid/src/vaadin-grid.d.ts index 084efbddd4..058306e0e3 100644 --- a/packages/grid/src/vaadin-grid.d.ts +++ b/packages/grid/src/vaadin-grid.d.ts @@ -205,6 +205,7 @@ export type GridDefaultItem = any; * `reorder-allowed-cell` | Cell in a column where another column can be reordered * `reorder-dragging-cell` | Cell in a column currently being reordered * `resize-handle` | Handle for resizing the columns + * `empty-state` | The container for the content to be displayed when there are no body rows to show * `reorder-ghost` | Ghost element of the header cell being dragged * * The following state attributes are available for styling: diff --git a/packages/grid/src/vaadin-grid.js b/packages/grid/src/vaadin-grid.js index 6b23311e1b..00442bb8f0 100644 --- a/packages/grid/src/vaadin-grid.js +++ b/packages/grid/src/vaadin-grid.js @@ -205,6 +205,7 @@ registerStyles('vaadin-grid', gridStyles, { moduleId: 'vaadin-grid-styles' }); * `reorder-allowed-cell` | Cell in a column where another column can be reordered * `reorder-dragging-cell` | Cell in a column currently being reordered * `resize-handle` | Handle for resizing the columns + * `empty-state` | The container for the content to be displayed when there are no body rows to show * `reorder-ghost` | Ghost element of the header cell being dragged * * The following state attributes are available for styling: @@ -268,11 +269,19 @@ class Grid extends GridMixin(ElementMixin(ThemableMixin(ControllerMixin(PolymerE ios$="[[_ios]]" loading$="[[loading]]" column-reordering-allowed$="[[columnReorderingAllowed]]" + empty-state$="[[__emptyState]]" > + + + + +
+ +
diff --git a/packages/grid/src/vaadin-lit-grid.js b/packages/grid/src/vaadin-lit-grid.js index a19746ff8b..8ea129a989 100644 --- a/packages/grid/src/vaadin-lit-grid.js +++ b/packages/grid/src/vaadin-lit-grid.js @@ -40,11 +40,19 @@ class Grid extends GridMixin(ElementMixin(ThemableMixin(PolylitMixin(LitElement) ios="${isIOS}" ?loading="${this.loading}" column-reordering-allowed="${this.columnReorderingAllowed}" + ?empty-state="${this.__emptyState}" > + + + + +
+ +
diff --git a/packages/grid/test/accessibility.common.js b/packages/grid/test/accessibility.common.js index b16dd7a79b..4e00b3e6b2 100644 --- a/packages/grid/test/accessibility.common.js +++ b/packages/grid/test/accessibility.common.js @@ -269,7 +269,9 @@ describe('accessibility', () => { col.path = 'value'; flushGrid(grid); - const rowCount = Array.from(grid.$.table.querySelectorAll('tr')).filter((tr) => !tr.hidden).length; + const rowCount = Array.from(grid.$.table.querySelectorAll('tr:not(#emptystaterow)')).filter( + (tr) => !tr.hidden, + ).length; expect(grid.$.table.getAttribute('aria-rowcount')).to.equal(String(rowCount)); expect(grid.$.table.getAttribute('aria-rowcount')).to.equal('3'); }); @@ -279,7 +281,9 @@ describe('accessibility', () => { col.header = null; flushGrid(grid); - const rowCount = Array.from(grid.$.table.querySelectorAll('tr')).filter((tr) => !tr.hidden).length; + const rowCount = Array.from(grid.$.table.querySelectorAll('tr:not(#emptystaterow)')).filter( + (tr) => !tr.hidden, + ).length; expect(grid.$.table.getAttribute('aria-rowcount')).to.equal(String(rowCount)); expect(grid.$.table.getAttribute('aria-rowcount')).to.equal('2'); }); @@ -323,7 +327,7 @@ describe('accessibility', () => { } it('should have aria-rowindex on rows', () => { - Array.from(grid.$.table.querySelectorAll('tr')).forEach((row, index) => { + Array.from(grid.$.table.querySelectorAll('tr:not(#emptystaterow)')).forEach((row, index) => { expect(row.getAttribute('aria-rowindex')).to.equal((index + 1).toString()); }); }); diff --git a/packages/grid/test/basic.common.js b/packages/grid/test/basic.common.js index 06dcb29969..bae8ceccb1 100644 --- a/packages/grid/test/basic.common.js +++ b/packages/grid/test/basic.common.js @@ -50,7 +50,7 @@ describe('basic features', () => { it('check visible item count', () => { grid.size = 10; flushGrid(grid); - expect(grid.shadowRoot.querySelectorAll('tbody tr:not([hidden])').length).to.eql(10); + expect(grid.shadowRoot.querySelectorAll('tbody tr:not([hidden]):not(#emptystaterow)').length).to.eql(10); }); it('first visible item', () => { @@ -363,3 +363,126 @@ describe('flex child', () => { }); }); }); + +describe('empty state', () => { + let grid; + + function getEmptyState() { + return grid.querySelector('[slot="empty-state"]'); + } + + function emptyStateVisible() { + return getEmptyState()?.offsetHeight > 0; + } + + function itemsBodyVisible() { + return grid.$.items.offsetHeight > 0; + } + + beforeEach(async () => { + grid = fixtureSync(` + + +
+ No items +
+
+ `); + + grid.querySelector('vaadin-grid-column').footerRenderer = (root) => { + root.textContent = 'Footer'; + }; + await nextFrame(); + }); + + it('should show empty state', () => { + expect(emptyStateVisible()).to.be.true; + expect(itemsBodyVisible()).to.be.false; + }); + + it('should not show empty state when grid has items', async () => { + grid.items = [{ name: 'foo' }]; + await nextFrame(); + expect(emptyStateVisible()).to.be.false; + expect(itemsBodyVisible()).to.be.true; + }); + + it('should not show empty state when empty state content is not defined', async () => { + grid.removeChild(getEmptyState()); + await nextFrame(); + expect(emptyStateVisible()).to.be.false; + expect(itemsBodyVisible()).to.be.true; + }); + + it('should not throw on empty state click', () => { + expect(() => getEmptyState().click()).not.to.throw(); + }); + + it('should not dispatch cell-activate on empty state click', () => { + const spy = sinon.spy(); + grid.addEventListener('cell-activate', spy); + getEmptyState().click(); + expect(spy.called).to.be.false; + }); + + describe('bounds', () => { + let gridRect, emptyStateCellRect, headerRect, footerRect; + + beforeEach(() => { + gridRect = grid.getBoundingClientRect(); + emptyStateCellRect = grid.$.emptystatecell.getBoundingClientRect(); + headerRect = grid.$.header.getBoundingClientRect(); + footerRect = grid.$.footer.getBoundingClientRect(); + }); + + it('should cover the viewport', () => { + expect(emptyStateCellRect.top).to.be.closeTo(headerRect.bottom, 1); + expect(emptyStateCellRect.bottom).to.be.closeTo(footerRect.top, 1); + expect(emptyStateCellRect.left).to.be.closeTo(gridRect.left, 1); + expect(emptyStateCellRect.right).to.be.closeTo(gridRect.right, 1); + }); + + it('should push footer to the bottom of the viewport', () => { + expect(footerRect.bottom).to.be.closeTo(gridRect.bottom, 1); + }); + + it('should not scroll horizontally with the columns', () => { + grid.append( + ...Array.from({ length: 10 }, () => fixtureSync('')), + ); + flushGrid(grid); + + grid.$.table.scrollLeft = grid.$.table.scrollWidth; + emptyStateCellRect = grid.$.emptystatecell.getBoundingClientRect(); + expect(emptyStateCellRect.left).to.be.closeTo(gridRect.left, 1); + expect(emptyStateCellRect.right).to.be.closeTo(gridRect.right, 1); + }); + + it('should not scroll verticaly with the columns', () => { + getEmptyState().innerHTML = Array.from({ length: 10 }, () => '

Lorem ipsum dolor sit amet

').join(''); + flushGrid(grid); + + grid.$.emptystatecell.scrollTop = grid.$.emptystatecell.scrollHeight; + expect(grid.$.emptystatecell.scrollTop).to.be.greaterThan(0); + emptyStateCellRect = grid.$.emptystatecell.getBoundingClientRect(); + expect(emptyStateCellRect.top).to.be.closeTo(headerRect.bottom, 1); + expect(emptyStateCellRect.bottom).to.be.closeTo(footerRect.top, 1); + }); + + it('should not overflow on all-rows-visible', () => { + grid.allRowsVisible = true; + grid.style.width = '200px'; + getEmptyState().innerHTML = Array.from({ length: 10 }, () => '

Lorem ipsum dolor sit amet

').join(''); + flushGrid(grid); + + const emptyStateRect = getEmptyState().getBoundingClientRect(); + gridRect = grid.getBoundingClientRect(); + headerRect = grid.$.header.getBoundingClientRect(); + footerRect = grid.$.footer.getBoundingClientRect(); + expect(emptyStateRect.top).to.be.greaterThan(headerRect.bottom); + expect(emptyStateRect.bottom).to.be.lessThan(footerRect.top); + expect(emptyStateRect.left).to.be.greaterThan(gridRect.left); + expect(emptyStateRect.right).to.be.lessThan(gridRect.right); + }); + }); +}); diff --git a/packages/grid/test/dom/__snapshots__/grid.test.snap.js b/packages/grid/test/dom/__snapshots__/grid.test.snap.js index 745425f8a4..13ea28d52f 100644 --- a/packages/grid/test/dom/__snapshots__/grid.test.snap.js +++ b/packages/grid/test/dom/__snapshots__/grid.test.snap.js @@ -196,6 +196,21 @@ snapshots["vaadin-grid shadow default"] = + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + { let translateValue; beforeEach(() => { - containerElement = grid.shadowRoot.querySelector(container === 'header' ? 'thead' : 'tbody'); + containerElement = grid.shadowRoot.querySelector(container === 'header' ? 'thead' : 'tbody#items'); containerRows = getRows(containerElement); scrollbarWidth = grid.$.table.offsetWidth - grid.$.table.clientWidth; borderWidth = parseInt(getComputedStyle(grid).getPropertyValue('--_lumo-grid-border-width')); diff --git a/packages/grid/test/helpers.js b/packages/grid/test/helpers.js index 1e5839d3c3..d4ee0f3845 100644 --- a/packages/grid/test/helpers.js +++ b/packages/grid/test/helpers.js @@ -93,7 +93,7 @@ const isVisible = (item, grid) => { }; export const getPhysicalItems = (grid) => { - return Array.from(grid.shadowRoot.querySelector('tbody').children) + return Array.from(grid.shadowRoot.querySelector('tbody#items').children) .filter((item) => !item.hidden) .sort((a, b) => a.index - b.index); }; diff --git a/packages/grid/test/keyboard-navigation.common.js b/packages/grid/test/keyboard-navigation.common.js index 12573f136c..92db0307d0 100644 --- a/packages/grid/test/keyboard-navigation.common.js +++ b/packages/grid/test/keyboard-navigation.common.js @@ -185,7 +185,16 @@ function getFocusedRowIndex() { } function getTabbableElements(root) { - return root.querySelectorAll('[tabindex]:not([tabindex="-1"])'); + return [...root.querySelectorAll('[tabindex]:not([tabindex="-1"])')].filter((el) => { + let parent = el; + while (parent) { + if (getComputedStyle(parent).display === 'none') { + return false; + } + parent = parent.parentElement; + } + return true; + }); } function getTabbableCells(root) { @@ -2585,3 +2594,89 @@ describe('lazy data provider', () => { expect(cellFocusSpy.called).to.be.false; }); }); + +describe('empty state', () => { + function getEmptyState() { + return grid.querySelector('[slot="empty-state"]'); + } + + function getEmptyStateFocusables() { + return [...getEmptyState().querySelectorAll('button')]; + } + + function getEmptyStateBody() { + return grid.$.emptystatebody; + } + + beforeEach(async () => { + grid = fixtureSync(` + + +
+ No items +
+
+ `); + await nextFrame(); + }); + + it('should tab to empty state body', async () => { + tabToHeader(); + tab(); + + await nextFrame(); + expect(getEmptyStateBody().contains(grid.shadowRoot.activeElement)).to.be.true; + expect(getEmptyStateBody().querySelector('td[part~="focused-cell"]')).to.be.ok; + }); + + it('should shift tab back to header from empty state body', () => { + tabToHeader(); + tab(); + shiftTab(); + + expect(grid.$.header.contains(grid.shadowRoot.activeElement)).to.be.true; + expect(getEmptyStateBody().querySelector('td[part~="focused-cell"]')).to.be.null; + }); + + it('should enter interaction mode on empty state body', () => { + tabToHeader(); + tab(); + enter(); + + expect(grid.hasAttribute('interacting')).to.be.true; + }); + + it('should focus the first focusable element in empty state content', () => { + tabToHeader(); + tab(); + enter(); + expect(document.activeElement).to.equal(getEmptyStateFocusables()[0]); + }); + + it('should tab to the next focusable element in empty state content', async () => { + tabToHeader(); + tab(); + enter(); + await sendKeys({ press: 'Tab' }); + expect(document.activeElement).to.equal(getEmptyStateFocusables()[1]); + }); + + it('should exit interaction mode on escape on empty state content', () => { + tabToHeader(); + tab(); + enter(); + escape(document.activeElement); + expect(grid.hasAttribute('interacting')).to.be.false; + expect(getEmptyStateBody().contains(grid.shadowRoot.activeElement)).to.be.true; + }); + + it('should not dispatch cell-focus event on empty state body', () => { + tabToHeader(); + + const spy = sinon.spy(); + grid.addEventListener('cell-focus', spy); + tab(); + + expect(spy.called).to.be.false; + }); +}); diff --git a/packages/grid/test/visual/grid.common.js b/packages/grid/test/visual/grid.common.js index 59e64067a1..0bd6b053be 100644 --- a/packages/grid/test/visual/grid.common.js +++ b/packages/grid/test/visual/grid.common.js @@ -434,4 +434,33 @@ describe('grid', () => { await visualDiff(element, 'tree-overflow'); }); }); + + describe('empty state', () => { + beforeEach(async () => { + element = fixtureSync(` + + + + + +
+ Lorem ipsum dolor sit amet, consectetur adipisicing elit. Distinctio laborum optio quo perferendis unde, fuga reprehenderit molestias cum laboriosam ipsa enim voluptatem iusto fugit. Sed, veniam repudiandae consectetur recusandae laudantium. +
+
+ `); + + [...element.querySelectorAll('vaadin-grid-column')].forEach((col) => { + col.footerRenderer = (root) => { + root.textContent = 'footer'; + }; + }); + + flushGrid(element); + await nextRender(element); + }); + + it('default', async () => { + await visualDiff(element, 'empty-state'); + }); + }); }); diff --git a/packages/grid/test/visual/lumo/grid.test.js b/packages/grid/test/visual/lumo/grid.test.js index a0a31e3629..198d6c4584 100644 --- a/packages/grid/test/visual/lumo/grid.test.js +++ b/packages/grid/test/visual/lumo/grid.test.js @@ -36,6 +36,19 @@ describe('theme', () => { await visualDiff(element, 'theme-compact'); }); + it('empty state compact', async () => { + element.setAttribute('theme', 'compact'); + element.items = []; + element.appendChild( + fixtureSync(` +
+ No items found. +
+ `), + ); + await visualDiff(element, 'empty-state-compact'); + }); + it('no-row-borders', async () => { element.setAttribute('theme', 'no-row-borders'); await visualDiff(element, 'theme-no-row-borders'); diff --git a/packages/grid/test/visual/lumo/screenshots/grid/baseline/empty-state-compact.png b/packages/grid/test/visual/lumo/screenshots/grid/baseline/empty-state-compact.png new file mode 100644 index 0000000000..c25185a82d Binary files /dev/null and b/packages/grid/test/visual/lumo/screenshots/grid/baseline/empty-state-compact.png differ diff --git a/packages/grid/test/visual/lumo/screenshots/grid/baseline/empty-state.png b/packages/grid/test/visual/lumo/screenshots/grid/baseline/empty-state.png new file mode 100644 index 0000000000..9dd4955844 Binary files /dev/null and b/packages/grid/test/visual/lumo/screenshots/grid/baseline/empty-state.png differ diff --git a/packages/grid/test/visual/material/screenshots/grid/baseline/empty-state.png b/packages/grid/test/visual/material/screenshots/grid/baseline/empty-state.png new file mode 100644 index 0000000000..0dcf3c2013 Binary files /dev/null and b/packages/grid/test/visual/material/screenshots/grid/baseline/empty-state.png differ diff --git a/packages/grid/theme/lumo/vaadin-grid-styles.js b/packages/grid/theme/lumo/vaadin-grid-styles.js index 9a9bdbad37..aa18393d03 100644 --- a/packages/grid/theme/lumo/vaadin-grid-styles.js +++ b/packages/grid/theme/lumo/vaadin-grid-styles.js @@ -90,6 +90,12 @@ registerStyles( z-index: 3; } + /* Empty state */ + [part~='empty-state'] { + padding: var(--lumo-space-m); + color: var(--lumo-secondary-text-color); + } + /* Drag and Drop styles */ :host([dragover])::after { content: ''; @@ -346,6 +352,10 @@ registerStyles( min-height: calc(var(--lumo-size-s) - var(--_lumo-grid-border-width)); } + :host([theme~='compact']) [part~='empty-state'] { + padding: var(--lumo-space-s); + } + /* Wrap cell contents */ :host([theme~='wrap-cell-content']) [part~='cell'] ::slotted(vaadin-grid-cell-content) { diff --git a/packages/grid/theme/material/vaadin-grid-styles.js b/packages/grid/theme/material/vaadin-grid-styles.js index 0ac16e5d60..42dea8babe 100644 --- a/packages/grid/theme/material/vaadin-grid-styles.js +++ b/packages/grid/theme/material/vaadin-grid-styles.js @@ -151,6 +151,12 @@ registerStyles( z-index: 3; } + /* Empty state */ + [part~='empty-state'] { + padding: 16px; + color: var(--material-secondary-text-color); + } + /* Drag and Drop styles */ :host([dragover])::after { content: '';