diff --git a/packages/grid/src/vaadin-grid-selection-column-base-mixin.js b/packages/grid/src/vaadin-grid-selection-column-base-mixin.js index 22641e9040..956ec89013 100644 --- a/packages/grid/src/vaadin-grid-selection-column-base-mixin.js +++ b/packages/grid/src/vaadin-grid-selection-column-base-mixin.js @@ -145,6 +145,10 @@ export const GridSelectionColumnBaseMixin = (superClass) => checkbox.__item = item; checkbox.__rendererChecked = selected; checkbox.checked = selected; + + const isSelectable = this._grid.__isItemSelectable(item); + checkbox.readonly = !isSelectable; + checkbox.hidden = !isSelectable && !selected; } /** @@ -245,9 +249,18 @@ export const GridSelectionColumnBaseMixin = (superClass) => _onCellKeyDown(e) { const target = e.composedPath()[0]; // Toggle on Space without having to enter interaction mode first - if (e.keyCode === 32 && (target === this._headerCell || (this._cells.includes(target) && !this.autoSelect))) { + if (e.keyCode !== 32) { + return; + } + if (target === this._headerCell) { + if (this.selectAll) { + this._deselectAll(); + } else { + this._selectAll(); + } + } else if (this._cells.includes(target) && !this.autoSelect) { const checkbox = target._content.firstElementChild; - checkbox.checked = !checkbox.checked; + this.__toggleItem(checkbox.__item); } } @@ -358,6 +371,19 @@ export const GridSelectionColumnBaseMixin = (superClass) => */ _deselectItem(_item) {} + /** + * Toggles the selected state of the given item. + * @param item the item to toggle + * @private + */ + __toggleItem(item) { + if (this._grid._isSelected(item)) { + this._deselectItem(item); + } else { + this._selectItem(item); + } + } + /** * IOS needs indeterminate + checked at the same time * @private diff --git a/packages/grid/src/vaadin-grid-selection-column-mixin.js b/packages/grid/src/vaadin-grid-selection-column-mixin.js index d5cdb3b344..8bd81283a6 100644 --- a/packages/grid/src/vaadin-grid-selection-column-mixin.js +++ b/packages/grid/src/vaadin-grid-selection-column-mixin.js @@ -30,14 +30,15 @@ export const GridSelectionColumnMixin = (superClass) => super(); this.__boundOnActiveItemChanged = this.__onActiveItemChanged.bind(this); - this.__boundOnDataProviderChanged = this.__onDataProviderChanged.bind(this); + this.__boundUpdateSelectAllVisibility = this.__updateSelectAllVisibility.bind(this); this.__boundOnSelectedItemsChanged = this.__onSelectedItemsChanged.bind(this); } /** @protected */ disconnectedCallback() { this._grid.removeEventListener('active-item-changed', this.__boundOnActiveItemChanged); - this._grid.removeEventListener('data-provider-changed', this.__boundOnDataProviderChanged); + this._grid.removeEventListener('data-provider-changed', this.__boundUpdateSelectAllVisibility); + this._grid.removeEventListener('is-item-selectable-changed', this.__boundUpdateSelectAllVisibility); this._grid.removeEventListener('filter-changed', this.__boundOnSelectedItemsChanged); this._grid.removeEventListener('selected-items-changed', this.__boundOnSelectedItemsChanged); @@ -49,7 +50,8 @@ export const GridSelectionColumnMixin = (superClass) => super.connectedCallback(); if (this._grid) { this._grid.addEventListener('active-item-changed', this.__boundOnActiveItemChanged); - this._grid.addEventListener('data-provider-changed', this.__boundOnDataProviderChanged); + this._grid.addEventListener('data-provider-changed', this.__boundUpdateSelectAllVisibility); + this._grid.addEventListener('is-item-selectable-changed', this.__boundUpdateSelectAllVisibility); this._grid.addEventListener('filter-changed', this.__boundOnSelectedItemsChanged); this._grid.addEventListener('selected-items-changed', this.__boundOnSelectedItemsChanged); } @@ -111,7 +113,9 @@ export const GridSelectionColumnMixin = (superClass) => * @override */ _selectItem(item) { - this._grid.selectItem(item); + if (this._grid.__isItemSelectable(item)) { + this._grid.selectItem(item); + } } /** @@ -123,7 +127,9 @@ export const GridSelectionColumnMixin = (superClass) => * @override */ _deselectItem(item) { - this._grid.deselectItem(item); + if (this._grid.__isItemSelectable(item)) { + this._grid.deselectItem(item); + } } /** @private */ @@ -132,7 +138,7 @@ export const GridSelectionColumnMixin = (superClass) => if (this.autoSelect) { const item = activeItem || this.__previousActiveItem; if (item) { - this._grid._toggleItem(item); + this.__toggleItem(item); } } this.__previousActiveItem = activeItem; @@ -164,8 +170,11 @@ export const GridSelectionColumnMixin = (superClass) => } /** @private */ - __onDataProviderChanged() { - this._selectAllHidden = !Array.isArray(this._grid.items); + __updateSelectAllVisibility() { + // Hide select all checkbox when we can not easily determine the select all checkbox state: + // - When using a custom data provider + // - When using conditional selection, where users may not select all items + this._selectAllHidden = !Array.isArray(this._grid.items) || !!this._grid.isItemSelectable; } /** diff --git a/packages/grid/src/vaadin-grid-selection-mixin.d.ts b/packages/grid/src/vaadin-grid-selection-mixin.d.ts index 6c1311090b..f17f76ec44 100644 --- a/packages/grid/src/vaadin-grid-selection-mixin.d.ts +++ b/packages/grid/src/vaadin-grid-selection-mixin.d.ts @@ -28,4 +28,25 @@ export declare class SelectionMixinClass { * @param item The item object */ deselectItem(item: TItem): void; + + /** + * A function to check whether a specific item in the grid may be + * selected or deselected by the user. Used by the selection column to + * conditionally enable to disable checkboxes for individual items. This + * function does not prevent programmatic selection/deselection of + * items. Changing the function does not modify the currently selected + * items. + * + * Configuring this function hides the select all checkbox of the grid + * selection column, which means users can not select or deselect all + * items anymore, nor do they get feedback on whether all items are + * selected or not. + * + * Receives an item instance and should return a boolean indicating + * whether users may change the selection state of that item. + * + * @param item The item object + * @return Whether the item is selectable + */ + isItemSelectable: (item: TItem) => boolean; } diff --git a/packages/grid/src/vaadin-grid-selection-mixin.js b/packages/grid/src/vaadin-grid-selection-mixin.js index b61f4dd7d8..1dfe379730 100644 --- a/packages/grid/src/vaadin-grid-selection-mixin.js +++ b/packages/grid/src/vaadin-grid-selection-mixin.js @@ -22,6 +22,29 @@ export const SelectionMixin = (superClass) => sync: true, }, + /** + * A function to check whether a specific item in the grid may be + * selected or deselected by the user. Used by the selection column to + * conditionally enable to disable checkboxes for individual items. This + * function does not prevent programmatic selection/deselection of + * items. Changing the function does not modify the currently selected + * items. + * + * Configuring this function hides the select all checkbox of the grid + * selection column, which means users can not select or deselect all + * items anymore, nor do they get feedback on whether all items are + * selected or not. + * + * Receives an item instance and should return a boolean indicating + * whether users may change the selection state of that item. + * + * @type {(item: !GridItem) => boolean} + */ + isItemSelectable: { + type: Function, + notify: true, + }, + /** * Set of selected item ids * @private @@ -34,7 +57,7 @@ export const SelectionMixin = (superClass) => } static get observers() { - return ['__selectedItemsChanged(itemIdPath, selectedItems)']; + return ['__selectedItemsChanged(itemIdPath, selectedItems, isItemSelectable)']; } /** @@ -46,6 +69,22 @@ export const SelectionMixin = (superClass) => return this.__selectedKeys.has(this.getItemId(item)); } + /** + * Determines whether the selection state of an item may be changed by the + * user. + * + * @private + */ + __isItemSelectable(item) { + // Item is selectable by default if isItemSelectable is not configured + if (!this.isItemSelectable || !item) { + return true; + } + + // Otherwise, check isItemSelectable function + return this.isItemSelectable(item); + } + /** * Selects the given item. * @@ -70,21 +109,6 @@ export const SelectionMixin = (superClass) => } } - /** - * Toggles the selected state of the given item. - * - * @method toggle - * @param {!GridItem} item The item object - * @protected - */ - _toggleItem(item) { - if (!this._isSelected(item)) { - this.selectItem(item); - } else { - this.deselectItem(item); - } - } - /** @private */ __selectedItemsChanged() { this.requestContentUpdate(); diff --git a/packages/grid/test/helpers.js b/packages/grid/test/helpers.js index 63f252fc21..3a6b2f8a5e 100644 --- a/packages/grid/test/helpers.js +++ b/packages/grid/test/helpers.js @@ -158,16 +158,24 @@ export const getContainerCellContent = (container, row, col) => { return getCellContent(getContainerCell(container, row, col)); }; -export const getHeaderCellContent = (grid, row, col) => { +export const getHeaderCell = (grid, row, col) => { const container = grid.$.header; - return getContainerCellContent(container, row, col); + return getContainerCell(container, row, col); }; -export const getBodyCellContent = (grid, row, col) => { +export const getHeaderCellContent = (grid, row, col) => { + return getCellContent(getHeaderCell(grid, row, col)); +}; + +export const getBodyCell = (grid, row, col) => { const physicalItems = getPhysicalItems(grid); const physicalRow = physicalItems.find((item) => item.index === row); const cells = getRowCells(physicalRow); - return getCellContent(cells[col]); + return cells[col]; +}; + +export const getBodyCellContent = (grid, row, col) => { + return getCellContent(getBodyCell(grid, row, col)); }; export const fire = (type, detail, options) => { diff --git a/packages/grid/test/selectable-provider-lit.test.js b/packages/grid/test/selectable-provider-lit.test.js new file mode 100644 index 0000000000..46ad9a7029 --- /dev/null +++ b/packages/grid/test/selectable-provider-lit.test.js @@ -0,0 +1,3 @@ +import '../theme/lumo/lit-all-imports.js'; +import '../src/lit-all-imports.js'; +import './selectable-provider.common.js'; diff --git a/packages/grid/test/selectable-provider-polymer.test.js b/packages/grid/test/selectable-provider-polymer.test.js new file mode 100644 index 0000000000..9ed8937008 --- /dev/null +++ b/packages/grid/test/selectable-provider-polymer.test.js @@ -0,0 +1,2 @@ +import '../all-imports.js'; +import './selectable-provider.common.js'; diff --git a/packages/grid/test/selectable-provider.common.js b/packages/grid/test/selectable-provider.common.js new file mode 100644 index 0000000000..2cf5f32d92 --- /dev/null +++ b/packages/grid/test/selectable-provider.common.js @@ -0,0 +1,218 @@ +import { expect } from '@vaadin/chai-plugins'; +import { fixtureSync, nextFrame } from '@vaadin/testing-helpers'; +import { sendKeys } from '@web/test-runner-commands'; +import sinon from 'sinon'; +import { fire, flushGrid, getBodyCell, getBodyCellContent, getHeaderCellContent } from './helpers.js'; + +describe('selectable-provider', () => { + let grid; + let selectionColumn; + let selectAllCheckbox; + + function getItemCheckbox(rowIndex) { + return getBodyCellContent(grid, rowIndex, 0).querySelector('vaadin-checkbox'); + } + + beforeEach(async () => { + grid = fixtureSync(` + + + + + `); + + // setup 10 items, first 5 are non-selectable + grid.items = Array.from({ length: 10 }, (_, i) => { + return { index: i, name: `item ${i}` }; + }); + grid.isItemSelectable = (item) => item.index >= 5; + + flushGrid(grid); + await nextFrame(); + + selectionColumn = grid.querySelector('vaadin-grid-selection-column'); + selectAllCheckbox = getHeaderCellContent(grid, 0, 0).querySelector('vaadin-checkbox'); + }); + + describe('checkbox states', () => { + it('should hide checkboxes for non-selectable items that are not selected', () => { + for (let i = 0; i < grid.items.length; i++) { + expect(getItemCheckbox(i).readonly).to.equal(i < 5); + expect(getItemCheckbox(i).hidden).to.equal(i < 5); + } + }); + + it('should show readonly checkboxes for non-selectable items that are selected', () => { + grid.selectedItems = [...grid.items]; + + for (let i = 0; i < grid.items.length; i++) { + expect(getItemCheckbox(i).readonly).to.equal(i < 5); + expect(getItemCheckbox(i).hidden).to.be.false; + } + }); + + it('should update checkboxes when changing isItemSelectable', () => { + grid.isItemSelectable = (item) => item.index < 5; + flushGrid(grid); + + for (let i = 0; i < grid.items.length; i++) { + expect(getItemCheckbox(i).hidden).to.equal(i >= 5); + } + }); + }); + + describe('individual selection', () => { + it('should prevent selection on checkbox click', async () => { + // prevents selection for non-selectable items + getItemCheckbox(0).click(); + await nextFrame(); + expect(grid.selectedItems.length).to.equal(0); + + // allows selection for selectable items + getItemCheckbox(5).click(); + await nextFrame(); + expect(grid.selectedItems.length).to.equal(1); + }); + + it('should prevent deselection on checkbox click', async () => { + grid.selectedItems = [...grid.items]; + + // prevents deselection for non-selectable items + getItemCheckbox(0).click(); + await nextFrame(); + expect(grid.selectedItems.length).to.equal(10); + + // allows deselection for selectable items + getItemCheckbox(5).click(); + await nextFrame(); + expect(grid.selectedItems.length).to.equal(9); + }); + + it('should prevent selection on row click when using auto-select', () => { + selectionColumn.autoSelect = true; + + // prevents selection for non-selectable items + getBodyCellContent(grid, 0, 1).click(); + expect(grid.selectedItems.length).to.equal(0); + + // allows selection for selectable items + getBodyCellContent(grid, 5, 1).click(); + expect(grid.selectedItems.length).to.equal(1); + }); + + it('should prevent deselection on row click when using auto-select', () => { + grid.selectedItems = [...grid.items]; + selectionColumn.autoSelect = true; + + // prevents deselection for non-selectable items + getBodyCellContent(grid, 0, 1).click(); + expect(grid.selectedItems.length).to.equal(10); + + // allows deselection for selectable items + getBodyCellContent(grid, 5, 1).click(); + expect(grid.selectedItems.length).to.equal(9); + }); + + it('should prevent selection when pressing space on cell', async () => { + // prevents selection for non-selectable items + getBodyCell(grid, 0, 0).focus(); + await sendKeys({ press: 'Space' }); + expect(grid.selectedItems.length).to.equal(0); + + // allows selection for selectable items + getBodyCell(grid, 5, 0).focus(); + await sendKeys({ press: 'Space' }); + expect(grid.selectedItems.length).to.equal(1); + }); + + it('should prevent deselection when pressing space on cell', async () => { + grid.selectedItems = [...grid.items]; + + // prevents deselection for non-selectable items + getBodyCell(grid, 0, 0).focus(); + await sendKeys({ press: 'Space' }); + expect(grid.selectedItems.length).to.equal(10); + + // allows deselection for selectable items + getBodyCell(grid, 5, 0).focus(); + await sendKeys({ press: 'Space' }); + expect(grid.selectedItems.length).to.equal(9); + }); + }); + + describe('drag selection', () => { + let clock; + + beforeEach(() => { + selectionColumn.dragSelect = true; + clock = sinon.useFakeTimers({ + shouldClearNativeTimers: true, + }); + }); + + afterEach(() => { + clock.restore(); + }); + + function fireTrackEvent(targetCell, startCell, eventState) { + const targetCellRect = targetCell.getBoundingClientRect(); + const startCellRect = startCell.getBoundingClientRect(); + fire( + 'track', + { state: eventState, y: targetCellRect.y, dy: targetCellRect.y - startCellRect.y }, + { node: targetCell }, + ); + } + + it('should prevent selection when drag selecting', () => { + // drag select in reverse from selectable items to non-selectable items + for (let i = 9; i >= 0; i--) { + const cellContent = getBodyCellContent(grid, i, 0); + const eventState = i === 9 ? 'start' : i === 0 ? 'end' : 'track'; + fireTrackEvent(cellContent, cellContent, eventState); + clock.tick(10); + } + expect(grid.selectedItems.length).to.equal(5); + expect(grid.selectedItems).to.include.members(grid.items.slice(5)); + }); + + it('should prevent deselection when drag selecting', () => { + grid.selectedItems = [...grid.items]; + + // drag select in reverse from selectable items to non-selectable items + for (let i = 9; i >= 0; i--) { + const cellContent = getBodyCellContent(grid, i, 0); + const eventState = i === 9 ? 'start' : i === 0 ? 'end' : 'track'; + fireTrackEvent(cellContent, cellContent, eventState); + clock.tick(10); + } + expect(grid.selectedItems.length).to.equal(5); + expect(grid.selectedItems).to.include.members(grid.items.slice(0, 5)); + }); + }); + + describe('select all', () => { + it('should hide select all checkbox when using isItemSelectable provider', () => { + expect(selectAllCheckbox.hasAttribute('hidden')).to.be.true; + }); + + it('should show select all checkbox when removing isItemSelectable provider', async () => { + grid.isItemSelectable = null; + await nextFrame(); + + expect(selectAllCheckbox.hasAttribute('hidden')).to.be.false; + }); + }); + + describe('programmatic selection', () => { + it('should not prevent programmatic selection', () => { + grid.selectItem(grid.items[0]); + grid.selectItem(grid.items[1]); + expect(grid.selectedItems.length).to.equal(2); + + grid.deselectItem(grid.items[0]); + grid.deselectItem(grid.items[1]); + expect(grid.selectedItems.length).to.equal(0); + }); + }); +});