Skip to content

Commit

Permalink
feat: add slot for empty state content (#7429)
Browse files Browse the repository at this point in the history
  • Loading branch information
tomivirkki committed May 23, 2024
1 parent 04d5d73 commit 04107da
Show file tree
Hide file tree
Showing 20 changed files with 433 additions and 11 deletions.
2 changes: 1 addition & 1 deletion packages/grid/src/vaadin-grid-active-item-mixin.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
6 changes: 3 additions & 3 deletions packages/grid/src/vaadin-grid-keyboard-navigation-mixin.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
];
Expand Down Expand Up @@ -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 } }));
}
Expand Down Expand Up @@ -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();
}
Expand Down
23 changes: 23 additions & 0 deletions packages/grid/src/vaadin-grid-mixin.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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)',
},
};
}

Expand Down Expand Up @@ -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 */
Expand Down Expand Up @@ -864,6 +882,11 @@ export const GridMixin = (superClass) =>
});
}

/** @private */
__computeEmptyState(flatSize, hasEmptyStateContent) {
return flatSize === 0 && hasEmptyStateContent;
}

/**
* @param {!Array<!GridColumn>} columnTree
* @protected
Expand Down
26 changes: 26 additions & 0 deletions packages/grid/src/vaadin-grid-styles.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
Expand Down
1 change: 1 addition & 0 deletions packages/grid/src/vaadin-grid.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
9 changes: 9 additions & 0 deletions packages/grid/src/vaadin-grid.js
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -268,11 +269,19 @@ class Grid extends GridMixin(ElementMixin(ThemableMixin(ControllerMixin(PolymerE
ios$="[[_ios]]"
loading$="[[loading]]"
column-reordering-allowed$="[[columnReorderingAllowed]]"
empty-state$="[[__emptyState]]"
>
<table id="table" role="treegrid" aria-multiselectable="true" tabindex="0">
<caption id="sizer" part="row"></caption>
<thead id="header" role="rowgroup"></thead>
<tbody id="items" role="rowgroup"></tbody>
<tbody id="emptystatebody">
<tr id="emptystaterow">
<td part="empty-state" id="emptystatecell" tabindex="0">
<slot name="empty-state" id="emptystateslot"></slot>
</td>
</tr>
</tbody>
<tfoot id="footer" role="rowgroup"></tfoot>
</table>
Expand Down
8 changes: 8 additions & 0 deletions packages/grid/src/vaadin-lit-grid.js
Original file line number Diff line number Diff line change
Expand Up @@ -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}"
>
<table id="table" role="treegrid" aria-multiselectable="true" tabindex="0">
<caption id="sizer" part="row"></caption>
<thead id="header" role="rowgroup"></thead>
<tbody id="items" role="rowgroup"></tbody>
<tbody id="emptystatebody">
<tr id="emptystaterow">
<td part="empty-state" id="emptystatecell" tabindex="0">
<slot name="empty-state" id="emptystateslot"></slot>
</td>
</tr>
</tbody>
<tfoot id="footer" role="rowgroup"></tfoot>
</table>
Expand Down
10 changes: 7 additions & 3 deletions packages/grid/test/accessibility.common.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
Expand All @@ -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');
});
Expand Down Expand Up @@ -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());
});
});
Expand Down
125 changes: 124 additions & 1 deletion packages/grid/test/basic.common.js
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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(`
<vaadin-grid>
<vaadin-grid-column path="name"></vaadin-grid-column>
<div slot="empty-state">
No items
</div>
</vaadin-grid>
`);

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('<vaadin-grid-column path="name"></vaadin-grid-column>')),
);
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 }, () => '<h2>Lorem ipsum dolor sit amet</h2>').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 }, () => '<h2>Lorem ipsum dolor sit amet</h2>').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);
});
});
});
Loading

0 comments on commit 04107da

Please sign in to comment.