From b64f6b6c627ae1296e177b861f6de5504f0e0eaa Mon Sep 17 00:00:00 2001 From: Tomi Virkki Date: Thu, 12 Sep 2024 16:18:01 +0300 Subject: [PATCH] feat: drag-resizing of dashboard items (#7791) --- dev/dashboard.html | 17 +- .../dashboard/src/vaadin-dashboard-widget.js | 28 + packages/dashboard/src/vaadin-dashboard.d.ts | 29 + packages/dashboard/src/vaadin-dashboard.js | 35 ++ .../src/widget-reorder-controller.js | 111 ++-- .../dashboard/src/widget-resize-controller.js | 186 ++++++ .../test/dashboard-widget-resizing.test.ts | 539 ++++++++++++++++++ packages/dashboard/test/helpers.ts | 35 +- .../dashboard/test/typings/dashboard.types.ts | 20 + 9 files changed, 941 insertions(+), 59 deletions(-) create mode 100644 packages/dashboard/src/widget-resize-controller.js create mode 100644 packages/dashboard/test/dashboard-widget-resizing.test.ts diff --git a/dev/dashboard.html b/dev/dashboard.html index 10f4c8bddc..e4ad7e8223 100644 --- a/dev/dashboard.html +++ b/dev/dashboard.html @@ -34,7 +34,8 @@ } .chart { - height: 300px; + height: 100%; + min-height: 300px; background: repeating-linear-gradient(45deg, #e0e0e0, #e0e0e0 10px, #f5f5f5 10px, #f5f5f5 20px); } @@ -102,6 +103,20 @@ console.log('dashboard-item-reorder-end'); console.log('items after reorder', e.target.items); }); + + dashboard.addEventListener('dashboard-item-resize-start', (e) => { + console.log('dashboard-item-resize-start', e.detail); + }); + + dashboard.addEventListener('dashboard-item-drag-resize', (e) => { + console.log('dashboard-item-drag-resize', e.detail); + // e.preventDefault(); + }); + + dashboard.addEventListener('dashboard-item-resize-end', (e) => { + console.log('dashboard-item-resize-end'); + console.log('item after resize', e.detail); + }); diff --git a/packages/dashboard/src/vaadin-dashboard-widget.js b/packages/dashboard/src/vaadin-dashboard-widget.js index 8efd5dd8da..3e46de5cf1 100644 --- a/packages/dashboard/src/vaadin-dashboard-widget.js +++ b/packages/dashboard/src/vaadin-dashboard-widget.js @@ -47,6 +47,32 @@ class DashboardWidget extends ControllerMixin(ElementMixin(PolylitMixin(LitEleme #content { flex: 1; + min-height: 100px; + } + + #resize-handle { + display: var(--_vaadin-dashboard-widget-actions-display, none); + } + + #resize-handle::before { + position: absolute; + bottom: 0; + right: 0; + font-size: 30px; + content: '\\2921'; + cursor: grab; + line-height: 1; + } + + :host::after { + content: ''; + z-index: 100; + position: absolute; + inset-inline-start: 0; + top: 0; + width: var(--_vaadin-dashboard-widget-resizer-width, 0); + height: var(--_vaadin-dashboard-widget-resizer-height, 0); + background: rgba(0, 0, 0, 0.1); } `, dashboardWidgetAndSectionStyles, @@ -80,6 +106,8 @@ class DashboardWidget extends ControllerMixin(ElementMixin(PolylitMixin(LitEleme
+ +
`; } diff --git a/packages/dashboard/src/vaadin-dashboard.d.ts b/packages/dashboard/src/vaadin-dashboard.d.ts index 9dac015b4b..5b5feb2c49 100644 --- a/packages/dashboard/src/vaadin-dashboard.d.ts +++ b/packages/dashboard/src/vaadin-dashboard.d.ts @@ -65,12 +65,41 @@ export type DashboardItemDragReorderEvent = CustomE targetIndex: number; }>; +/** + * Fired when item resizing starts + */ +export type DashboardItemResizeStartEvent = CustomEvent<{ + item: TItem; +}>; + +/** + * Fired when item resizing ends + */ +export type DashboardItemResizeEndEvent = CustomEvent<{ + item: TItem; +}>; + +/** + * Fired when an item will be resized by dragging + */ +export type DashboardItemDragResizeEvent = CustomEvent<{ + item: TItem; + colspan: number; + rowspan: number; +}>; + export interface DashboardCustomEventMap { 'dashboard-item-reorder-start': DashboardItemReorderStartEvent; 'dashboard-item-reorder-end': DashboardItemReorderEndEvent; 'dashboard-item-drag-reorder': DashboardItemDragReorderEvent; + + 'dashboard-item-resize-start': DashboardItemResizeStartEvent; + + 'dashboard-item-resize-end': DashboardItemResizeEndEvent; + + 'dashboard-item-drag-resize': DashboardItemDragResizeEvent; } export type DashboardEventMap = DashboardCustomEventMap & HTMLElementEventMap; diff --git a/packages/dashboard/src/vaadin-dashboard.js b/packages/dashboard/src/vaadin-dashboard.js index 6ac2aff8c8..d2d3e89399 100644 --- a/packages/dashboard/src/vaadin-dashboard.js +++ b/packages/dashboard/src/vaadin-dashboard.js @@ -19,6 +19,7 @@ import { css, ThemableMixin } from '@vaadin/vaadin-themable-mixin/vaadin-themabl import { DashboardLayoutMixin } from './vaadin-dashboard-layout-mixin.js'; import { hasWidgetWrappers } from './vaadin-dashboard-styles.js'; import { WidgetReorderController } from './widget-reorder-controller.js'; +import { WidgetResizeController } from './widget-resize-controller.js'; /** * A responsive, grid-based dashboard layout component @@ -26,6 +27,9 @@ import { WidgetReorderController } from './widget-reorder-controller.js'; * @fires {CustomEvent} dashboard-item-drag-reorder - Fired when an items will be reordered by dragging * @fires {CustomEvent} dashboard-item-reorder-start - Fired when item reordering starts * @fires {CustomEvent} dashboard-item-reorder-end - Fired when item reordering ends + * @fires {CustomEvent} dashboard-item-drag-resize - Fired when an item will be resized by dragging + * @fires {CustomEvent} dashboard-item-resize-start - Fired when item resizing starts + * @fires {CustomEvent} dashboard-item-resize-end - Fired when item resizing ends * * @customElement * @extends HTMLElement @@ -49,6 +53,11 @@ class Dashboard extends ControllerMixin(DashboardLayoutMixin(ElementMixin(Themab :host([editable]) { --_vaadin-dashboard-widget-actions-display: block; } + + #grid[resizing] { + -webkit-user-select: none; + user-select: none; + } `, hasWidgetWrappers, ]; @@ -98,12 +107,20 @@ class Dashboard extends ControllerMixin(DashboardLayoutMixin(ElementMixin(Themab constructor() { super(); this.__widgetReorderController = new WidgetReorderController(this); + this.__widgetResizeController = new WidgetResizeController(this); + } + + /** @protected */ + disconnectedCallback() { + super.disconnectedCallback(); + this.__widgetResizeController.cleanup(); } /** @protected */ ready() { super.ready(); this.addController(this.__widgetReorderController); + this.addController(this.__widgetResizeController); } /** @protected */ @@ -182,6 +199,24 @@ class Dashboard extends ControllerMixin(DashboardLayoutMixin(ElementMixin(Themab * * @event dashboard-item-drag-reorder */ + + /** + * Fired when item resizing starts + * + * @event dashboard-item-resize-start + */ + + /** + * Fired when item resizing ends + * + * @event dashboard-item-resize-end + */ + + /** + * Fired when an item will be resized by dragging + * + * @event dashboard-item-drag-resize + */ } defineCustomElement(Dashboard); diff --git a/packages/dashboard/src/widget-reorder-controller.js b/packages/dashboard/src/widget-reorder-controller.js index 4e39212446..9952181596 100644 --- a/packages/dashboard/src/widget-reorder-controller.js +++ b/packages/dashboard/src/widget-reorder-controller.js @@ -24,67 +24,72 @@ export class WidgetReorderController extends EventTarget { /** @private */ __dragStart(e) { - if ([...e.composedPath()].some((el) => el.classList && el.classList.contains('drag-handle'))) { - this.__draggedElement = e.target; - this.draggedItem = this.__getElementItem(this.__draggedElement); + const handle = [...e.composedPath()].find((el) => el.classList && el.classList.contains('drag-handle')); + if (!handle) { + return; + } - // Set the drag image to the dragged element - const { left, top } = this.__draggedElement.getBoundingClientRect(); - e.dataTransfer.setDragImage(this.__draggedElement, e.clientX - left, e.clientY - top); - // Set the text/plain data to enable dragging on mobile devices - e.dataTransfer.setData('text/plain', 'item'); + this.__draggedElement = e.target; + this.draggedItem = this.__getElementItem(this.__draggedElement); - // Observe the removal of the dragged element from the DOM - this.draggedElementRemoveObserver.observe(this.host, { childList: true, subtree: true }); + // Set the drag image to the dragged element + const { left, top } = this.__draggedElement.getBoundingClientRect(); + e.dataTransfer.setDragImage(this.__draggedElement, e.clientX - left, e.clientY - top); + // Set the text/plain data to enable dragging on mobile devices + e.dataTransfer.setData('text/plain', 'item'); - this.host.dispatchEvent(new CustomEvent('dashboard-item-reorder-start')); + // Observe the removal of the dragged element from the DOM + this.draggedElementRemoveObserver.observe(this.host, { childList: true, subtree: true }); - requestAnimationFrame(() => { - // Re-render to have the dragged element turn into a placeholder - this.host.items = [...this.host.items]; - }); - } + this.host.dispatchEvent(new CustomEvent('dashboard-item-reorder-start')); + + requestAnimationFrame(() => { + // Re-render to have the dragged element turn into a placeholder + this.host.items = [...this.host.items]; + }); } /** @private */ __dragOver(e) { - if (this.draggedItem) { - e.preventDefault(); - e.dataTransfer.dropEffect = 'move'; - - // Get all elements that are candidates for reordering with the dragged element - const dragContextElements = this.__getDragContextElements(this.__draggedElement); - // Find the up-to-date element instance representing the dragged item - const draggedElement = dragContextElements.find((element) => this.__getElementItem(element) === this.draggedItem); - if (!draggedElement) { - return; - } - // Get all elements except the dragged element from the drag context - const otherElements = dragContextElements.filter((element) => element !== draggedElement); - // Find the element closest to the x and y coordinates of the drag event - const closestElement = this.__getClosestElement(otherElements, e.clientX, e.clientY); - - // Check if the dragged element is dragged enough over the element closest to the drag event coordinates - if (!this.__reordering && this.__isDraggedOver(draggedElement, closestElement, e.clientX, e.clientY)) { - // Prevent reordering multiple times in quick succession - this.__reordering = true; - setTimeout(() => { - this.__reordering = false; - }, REORDER_EVENT_TIMEOUT); - - const targetItem = this.__getElementItem(closestElement); - const targetItems = this.__getItemsArrayOfItem(targetItem); - const targetIndex = targetItems.indexOf(targetItem); - - const reorderEvent = new CustomEvent('dashboard-item-drag-reorder', { - detail: { item: this.draggedItem, targetIndex }, - cancelable: true, - }); - - // Dispatch the reorder event and reorder items if the event is not canceled - if (this.host.dispatchEvent(reorderEvent)) { - this.__reorderItems(this.draggedItem, targetIndex); - } + if (!this.draggedItem) { + return; + } + + e.preventDefault(); + e.dataTransfer.dropEffect = 'move'; + + // Get all elements that are candidates for reordering with the dragged element + const dragContextElements = this.__getDragContextElements(this.__draggedElement); + // Find the up-to-date element instance representing the dragged item + const draggedElement = dragContextElements.find((element) => this.__getElementItem(element) === this.draggedItem); + if (!draggedElement) { + return; + } + // Get all elements except the dragged element from the drag context + const otherElements = dragContextElements.filter((element) => element !== draggedElement); + // Find the element closest to the x and y coordinates of the drag event + const closestElement = this.__getClosestElement(otherElements, e.clientX, e.clientY); + + // Check if the dragged element is dragged enough over the element closest to the drag event coordinates + if (!this.__reordering && this.__isDraggedOver(draggedElement, closestElement, e.clientX, e.clientY)) { + // Prevent reordering multiple times in quick succession + this.__reordering = true; + setTimeout(() => { + this.__reordering = false; + }, REORDER_EVENT_TIMEOUT); + + const targetItem = this.__getElementItem(closestElement); + const targetItems = this.__getItemsArrayOfItem(targetItem); + const targetIndex = targetItems.indexOf(targetItem); + + const reorderEvent = new CustomEvent('dashboard-item-drag-reorder', { + detail: { item: this.draggedItem, targetIndex }, + cancelable: true, + }); + + // Dispatch the reorder event and reorder items if the event is not canceled + if (this.host.dispatchEvent(reorderEvent)) { + this.__reorderItems(this.draggedItem, targetIndex); } } } diff --git a/packages/dashboard/src/widget-resize-controller.js b/packages/dashboard/src/widget-resize-controller.js new file mode 100644 index 0000000000..dd0c96f815 --- /dev/null +++ b/packages/dashboard/src/widget-resize-controller.js @@ -0,0 +1,186 @@ +/** + * @license + * Copyright (c) 2019 - 2024 Vaadin Ltd. + * This program is available under Apache License Version 2.0, available at https://vaadin.com/license/ + */ + +const WRAPPER_LOCAL_NAME = 'vaadin-dashboard-widget-wrapper'; +import { addListener } from '@vaadin/component-base/src/gestures.js'; + +/** + * A controller to widget resizing inside a dashboard. + */ +export class WidgetResizeController extends EventTarget { + constructor(host) { + super(); + this.host = host; + this.__resizedElementRemoveObserver = new MutationObserver(() => this.__restoreResizedElement()); + this.__touchMoveCancelListener = (e) => e.preventDefault(); + addListener(host, 'track', (e) => this.__onTrack(e)); + } + + /** @private */ + __onTrack(e) { + if (e.detail.state === 'start') { + this.__onResizeStart(e); + } else if (e.detail.state === 'track') { + this.__onResize(e); + } else if (e.detail.state === 'end') { + this.__onResizeEnd(e); + } + } + + /** @private */ + __onResizeStart(e) { + const handle = [...e.composedPath()].find((el) => el.classList && el.classList.contains('resize-handle')); + if (!handle) { + return; + } + + this.host.$.grid.toggleAttribute('resizing', true); + this.resizedItem = this.__getElementItem(e.target); + + this.__resizeStartWidth = e.target.offsetWidth; + this.__resizeStartHeight = e.target.offsetHeight; + this.__resizeWidth = this.__resizeStartWidth + e.detail.dx; + this.__resizeHeight = this.__resizeStartHeight + e.detail.dy; + this.__updateWidgetStyles(); + + this.host.dispatchEvent(new CustomEvent('dashboard-item-resize-start', { detail: { item: this.resizedItem } })); + + this.__resizedElement = e.target; + // Observe the removal of the resized element from the DOM + this.__resizedElementRemoveObserver.observe(this.host, { childList: true, subtree: true }); + + // Prevent scrolling on touch devices while resizing + document.addEventListener('touchmove', this.__touchMoveCancelListener, { passive: false }); + } + + /** @private */ + __onResize(e) { + if (!this.resizedItem) { + return; + } + + this.__resizeWidth = this.__resizeStartWidth + e.detail.dx; + this.__resizeHeight = this.__resizeStartHeight + e.detail.dy; + this.__updateWidgetStyles(); + + const itemWrapper = this.__getItemWrapper(this.resizedItem); + if (!itemWrapper.firstElementChild) { + return; + } + + const gridStyle = getComputedStyle(this.host.$.grid); + const gapSize = parseFloat(gridStyle.gap || 0); + + const currentElementWidth = itemWrapper.firstElementChild.offsetWidth; + const columns = gridStyle.gridTemplateColumns.split(' '); + const columnWidth = parseFloat(columns[0]); + if (this.__resizeWidth > currentElementWidth + gapSize + columnWidth / 2) { + // Resized horizontally above the half of the next column, increase colspan + this.__updateResizedItem(Math.min((this.resizedItem.colspan || 1) + 1, columns.length), this.resizedItem.rowspan); + } else if (this.__resizeWidth < currentElementWidth - columnWidth / 2) { + // Resized horizontally below the half of the current column, decrease colspan + this.__updateResizedItem(Math.max((this.resizedItem.colspan || 1) - 1, 1), this.resizedItem.rowspan); + } + + if (!gridStyle.getPropertyValue('--vaadin-dashboard-row-min-height')) { + return; + } + + const currentElementHeight = itemWrapper.firstElementChild.offsetHeight; + const rowMinHeight = Math.min(...gridStyle.gridTemplateRows.split(' ').map((height) => parseFloat(height))); + if (this.__resizeHeight > currentElementHeight + gapSize + rowMinHeight / 2) { + // Resized vertically above the half of the next row, increase rowspan + this.__updateResizedItem(this.resizedItem.colspan, (this.resizedItem.rowspan || 1) + 1); + } else if (this.__resizeHeight < currentElementHeight - rowMinHeight / 2) { + // Resized vertically below the half of the current row, decrease rowspan + this.__updateResizedItem(this.resizedItem.colspan, Math.max((this.resizedItem.rowspan || 1) - 1, 1)); + } + } + + /** @private */ + __onResizeEnd() { + if (!this.resizedItem) { + return; + } + + // If the originally resized element is restored to the DOM (as a direct child of the host), + // to make sure "track" event gets dispatched, remove it to avoid duplicates + if (this.__resizedElement.parentElement === this.host) { + this.__resizedElement.remove(); + } + + const itemWrapper = this.__getItemWrapper(this.resizedItem); + itemWrapper.style.removeProperty('--_vaadin-dashboard-widget-resizer-width'); + itemWrapper.style.removeProperty('--_vaadin-dashboard-widget-resizer-height'); + + this.host.$.grid.toggleAttribute('resizing', false); + + // Disconnect the observer for the resized element removal + this.__resizedElementRemoveObserver.disconnect(); + // Cleanup the touchmove listener + this.cleanup(); + + // Dispatch the resize end event + this.host.dispatchEvent( + new CustomEvent('dashboard-item-resize-end', { + detail: { item: this.resizedItem }, + cancelable: true, + }), + ); + this.resizedItem = null; + } + + /** @private */ + __getElementItem(element) { + return element.closest(WRAPPER_LOCAL_NAME).__item; + } + + /** @private */ + __getItemWrapper(item) { + return [...this.host.querySelectorAll(WRAPPER_LOCAL_NAME)].find((el) => el.__item === item); + } + + /** @private */ + __updateResizedItem(colspan = 1, rowspan = 1) { + if ((this.resizedItem.colspan || 1) === colspan && (this.resizedItem.rowspan || 1) === rowspan) { + return; + } + + const resizeEvent = new CustomEvent('dashboard-item-drag-resize', { + detail: { item: this.resizedItem, colspan, rowspan }, + cancelable: true, + }); + + // Dispatch the resize event and resize items if the event is not canceled + if (!this.host.dispatchEvent(resizeEvent)) { + return; + } + + this.resizedItem.colspan = colspan; + this.resizedItem.rowspan = rowspan; + this.host.items = [...this.host.items]; + requestAnimationFrame(() => this.__updateWidgetStyles()); + } + + /** @private */ + __updateWidgetStyles() { + const itemWrapper = this.__getItemWrapper(this.resizedItem); + itemWrapper.style.setProperty('--_vaadin-dashboard-widget-resizer-width', `${this.__resizeWidth}px`); + itemWrapper.style.setProperty('--_vaadin-dashboard-widget-resizer-height', `${this.__resizeHeight}px`); + } + + /** @private */ + __restoreResizedElement() { + if (!this.host.contains(this.__resizedElement)) { + this.__resizedElement.style.display = 'none'; + this.host.appendChild(this.__resizedElement); + } + } + + cleanup() { + document.removeEventListener('touchmove', this.__touchMoveCancelListener); + } +} diff --git a/packages/dashboard/test/dashboard-widget-resizing.test.ts b/packages/dashboard/test/dashboard-widget-resizing.test.ts new file mode 100644 index 0000000000..18e630e9d3 --- /dev/null +++ b/packages/dashboard/test/dashboard-widget-resizing.test.ts @@ -0,0 +1,539 @@ +import { expect } from '@vaadin/chai-plugins'; +import { fixtureSync, nextFrame } from '@vaadin/testing-helpers'; +import sinon from 'sinon'; +import '../vaadin-dashboard.js'; +import { isSafari } from '@vaadin/component-base/src/browser-utils.js'; +import type { Dashboard, DashboardItem } from '../vaadin-dashboard.js'; +import { + expectLayout, + fireResizeEnd, + fireResizeOver, + fireResizeStart, + getElementFromCell, + setGap, + setMaximumColumnWidth, + setMinimumColumnWidth, + setMinimumRowHeight, +} from './helpers.js'; + +type TestDashboardItem = DashboardItem & { id: number }; + +describe('dashboard - widget resizing', () => { + let dashboard: Dashboard; + const columnWidth = 100; + const rowHeight = 100; + + beforeEach(async () => { + dashboard = fixtureSync(''); + dashboard.style.width = `${columnWidth * 2}px`; + setMinimumColumnWidth(dashboard, columnWidth); + setMaximumColumnWidth(dashboard, columnWidth); + setGap(dashboard, 0); + setMinimumRowHeight(dashboard, rowHeight); + + dashboard.editable = true; + + dashboard.items = [{ id: 0 }, { id: 1 }]; + dashboard.renderer = (root, _, model) => { + root.textContent = ''; + const widget = fixtureSync(` + +
Widget content
+
`); + root.appendChild(widget); + }; + await nextFrame(); + + // prettier-ignore + expectLayout(dashboard, [ + [0, 1], + ]); + }); + + describe('mouse drag', () => { + it('should resize a widget while dragging (start -> end)', async () => { + // Start dragging the first widget resize handle + fireResizeStart(getElementFromCell(dashboard, 0, 0)!); + await nextFrame(); + + // Drag over the end edge of the second one + fireResizeOver(getElementFromCell(dashboard, 0, 1)!, 'end'); + await nextFrame(); + + // Expect the widgets to be reordered + // prettier-ignore + expectLayout(dashboard, [ + [0, 0], + [1], + ]); + }); + + it('should not resize if dragged barely over another widget (start -> end)', async () => { + fireResizeStart(getElementFromCell(dashboard, 0, 0)!); + await nextFrame(); + + fireResizeOver(getElementFromCell(dashboard, 0, 1)!, 'start'); + await nextFrame(); + + // prettier-ignore + expectLayout(dashboard, [ + [0, 1], + ]); + }); + + it('should resize a widget while dragging (end -> start)', async () => { + dashboard.items = [{ id: 0, colspan: 2 }, { id: 1 }]; + await nextFrame(); + // prettier-ignore + expectLayout(dashboard, [ + [0, 0], + [1], + ]); + + fireResizeStart(getElementFromCell(dashboard, 0, 1)!); + await nextFrame(); + + fireResizeOver(getElementFromCell(dashboard, 0, 0)!, 'start'); + await nextFrame(); + + // prettier-ignore + expectLayout(dashboard, [ + [0, 1], + ]); + }); + + it('should not resize if dragged barely over another widget (end -> start)', async () => { + dashboard.items = [{ id: 0, colspan: 2 }, { id: 1 }]; + await nextFrame(); + // prettier-ignore + expectLayout(dashboard, [ + [0, 0], + [1], + ]); + + fireResizeStart(getElementFromCell(dashboard, 0, 1)!); + await nextFrame(); + + fireResizeOver(getElementFromCell(dashboard, 0, 0)!, 'end'); + await nextFrame(); + + // prettier-ignore + expectLayout(dashboard, [ + [0, 0], + [1], + ]); + }); + + it('should resize a widget while dragging (top -> bottom)', async () => { + dashboard.items = [{ id: 0 }, { id: 1 }, { id: 2 }]; + await nextFrame(); + // prettier-ignore + expectLayout(dashboard, [ + [0, 1], + [2], + ]); + + fireResizeStart(getElementFromCell(dashboard, 0, 0)!); + await nextFrame(); + + fireResizeOver(getElementFromCell(dashboard, 1, 0)!, 'bottom'); + await nextFrame(); + + // prettier-ignore + expectLayout(dashboard, [ + [0, 1], + [0, 2], + ]); + }); + + it('should not resize if dragged barely over another widget (top -> bottom)', async () => { + dashboard.items = [{ id: 0 }, { id: 1 }, { id: 2 }]; + await nextFrame(); + // prettier-ignore + expectLayout(dashboard, [ + [0, 1], + [2], + ]); + + fireResizeStart(getElementFromCell(dashboard, 0, 0)!); + await nextFrame(); + + fireResizeOver(getElementFromCell(dashboard, 1, 0)!, 'top'); + await nextFrame(); + + // prettier-ignore + expectLayout(dashboard, [ + [0, 1], + [2], + ]); + }); + + it('should resize a widget while dragging (bottom -> top)', async () => { + dashboard.items = [{ id: 0, rowspan: 2 }, { id: 1 }, { id: 2 }]; + await nextFrame(); + // prettier-ignore + expectLayout(dashboard, [ + [0, 1], + [0, 2], + ]); + + fireResizeStart(getElementFromCell(dashboard, 1, 0)!); + await nextFrame(); + + fireResizeOver(getElementFromCell(dashboard, 0, 0)!, 'top'); + await nextFrame(); + + // prettier-ignore + expectLayout(dashboard, [ + [0, 1], + [2], + ]); + }); + + it('should not resize if dragged barely over another widget (bottom -> top)', async () => { + dashboard.items = [{ id: 0, rowspan: 2 }, { id: 1 }, { id: 2 }]; + await nextFrame(); + // prettier-ignore + expectLayout(dashboard, [ + [0, 1], + [0, 2], + ]); + + fireResizeStart(getElementFromCell(dashboard, 1, 0)!); + await nextFrame(); + + fireResizeOver(getElementFromCell(dashboard, 0, 0)!, 'bottom'); + await nextFrame(); + + // prettier-ignore + expectLayout(dashboard, [ + [0, 1], + [0, 2], + ]); + }); + + it('should not resize vertically if minimum row height is not defined', async () => { + setMinimumRowHeight(dashboard, undefined); + dashboard.items = [{ id: 0 }, { id: 1 }, { id: 2 }]; + await nextFrame(); + // prettier-ignore + expectLayout(dashboard, [ + [0, 1], + [2], + ]); + + fireResizeStart(getElementFromCell(dashboard, 0, 0)!); + await nextFrame(); + + fireResizeOver(getElementFromCell(dashboard, 1, 0)!, 'bottom'); + await nextFrame(); + + // prettier-ignore + expectLayout(dashboard, [ + [0, 1], + [2], + ]); + }); + + it('should not resize a widget if not dragging by the resize handle', async () => { + const widget = getElementFromCell(dashboard, 0, 0)!; + widget.shadowRoot?.querySelector('.resize-handle')?.remove(); + + // Start dragging the first widget by somewhere else than the resize handle + fireResizeStart(widget); + await nextFrame(); + + // Drag over the end edge of the second one + fireResizeOver(getElementFromCell(dashboard, 0, 1)!, 'end'); + await nextFrame(); + + // prettier-ignore + expectLayout(dashboard, [ + [0, 1], + ]); + }); + + it('should dispatch an item resize start event', async () => { + const resizeStartSpy = sinon.spy(); + dashboard.addEventListener('dashboard-item-resize-start', resizeStartSpy); + fireResizeStart(getElementFromCell(dashboard, 0, 0)!); + await nextFrame(); + + expect(resizeStartSpy).to.have.been.calledOnce; + expect(resizeStartSpy.getCall(0).args[0].detail).to.deep.equal({ + item: { id: 0 }, + }); + }); + + it('should dispatch an item drag resize event', async () => { + const resizeSpy = sinon.spy(); + dashboard.addEventListener('dashboard-item-drag-resize', resizeSpy); + dashboard.addEventListener('dashboard-item-drag-resize', (e) => e.preventDefault()); + fireResizeStart(getElementFromCell(dashboard, 0, 0)!); + await nextFrame(); + fireResizeOver(getElementFromCell(dashboard, 0, 1)!, 'end'); + await nextFrame(); + + expect(resizeSpy).to.have.been.calledOnce; + expect(resizeSpy.getCall(0).args[0].detail).to.deep.equal({ + item: { id: 0 }, + colspan: 2, + rowspan: 1, + }); + }); + + it('should not resize if the drag resize event is cancelled', async () => { + dashboard.addEventListener('dashboard-item-drag-resize', (e) => e.preventDefault()); + fireResizeStart(getElementFromCell(dashboard, 0, 0)!); + await nextFrame(); + fireResizeOver(getElementFromCell(dashboard, 0, 1)!, 'end'); + await nextFrame(); + // prettier-ignore + expectLayout(dashboard, [ + [0, 1], + ]); + }); + + // This test fails in Safari but only on CI. Locally it works fine. + (isSafari ? it.skip : it)('should not resize beyond effective column count', async () => { + const resizeSpy = sinon.spy(); + dashboard.addEventListener('dashboard-item-drag-resize', resizeSpy); + + const widget1Rect = getElementFromCell(dashboard, 0, 1)!.getBoundingClientRect(); + + // Narrow the dashboard to have only one column + dashboard.style.width = `${columnWidth}px`; + await nextFrame(); + // prettier-ignore + expectLayout(dashboard, [ + [0], + [1], + ]); + + fireResizeStart(getElementFromCell(dashboard, 0, 0)!); + // Try to resize the widget to the cover two columns + const x = widget1Rect.right; + const y = widget1Rect.bottom; + const event = new MouseEvent('mousemove', { + bubbles: true, + composed: true, + clientX: x, + clientY: y, + buttons: 1, + }); + dashboard.dispatchEvent(event); + await nextFrame(); + + expect(resizeSpy).to.not.have.been.called; + // prettier-ignore + expectLayout(dashboard, [ + [0], + [1], + ]); + }); + + it('should dispatch an item resize end event', async () => { + const resizeEndSpy = sinon.spy(); + dashboard.addEventListener('dashboard-item-resize-end', resizeEndSpy); + fireResizeStart(getElementFromCell(dashboard, 0, 0)!); + await nextFrame(); + fireResizeOver(getElementFromCell(dashboard, 0, 1)!, 'end'); + await nextFrame(); + fireResizeEnd(dashboard); + await nextFrame(); + + expect(resizeEndSpy).to.have.been.calledOnce; + expect(resizeEndSpy.getCall(0).args[0].detail).to.deep.equal({ + item: { id: 0, colspan: 2, rowspan: 1 }, + }); + }); + + it('should not dispatch an item reorder end event if drag has not started', async () => { + const resizeEndSpy = sinon.spy(); + dashboard.addEventListener('dashboard-item-resize-end', resizeEndSpy); + + const widget = getElementFromCell(dashboard, 0, 0)!; + widget.shadowRoot?.querySelector('.resize-handle')?.remove(); + // Start dragging the first widget by somewhere else than the resize handle + fireResizeStart(widget); + await nextFrame(); + + fireResizeEnd(dashboard); + await nextFrame(); + + expect(resizeEndSpy).to.not.have.been.called; + }); + + it('should cancel touchmove events while resizing', async () => { + fireResizeStart(getElementFromCell(dashboard, 0, 0)!); + await nextFrame(); + const touchmove = new Event('touchmove', { cancelable: true, bubbles: true }); + document.dispatchEvent(touchmove); + + expect(touchmove.defaultPrevented).to.be.true; + }); + + it('should not cancel touchmove events after resizing has finished', async () => { + fireResizeStart(getElementFromCell(dashboard, 0, 0)!); + await nextFrame(); + fireResizeEnd(dashboard); + + const touchmove = new Event('touchmove', { cancelable: true, bubbles: true }); + document.dispatchEvent(touchmove); + + expect(touchmove.defaultPrevented).to.be.false; + }); + + it('should prevent selection while resizing', async () => { + const propertyName = isSafari ? 'WebkitUserSelect' : 'userSelect'; + + expect(getComputedStyle((dashboard as any).$.grid)[propertyName]).not.to.equal('none'); + fireResizeStart(getElementFromCell(dashboard, 0, 0)!); + await nextFrame(); + + expect(getComputedStyle((dashboard as any).$.grid)[propertyName]).to.equal('none'); + + fireResizeEnd(dashboard); + await nextFrame(); + + expect(getComputedStyle((dashboard as any).$.grid)[propertyName]).not.to.equal('none'); + }); + + it('should not throw with a lazy renderer while resizing', async () => { + dashboard.style.width = `${columnWidth}px`; + await nextFrame(); + // prettier-ignore + expectLayout(dashboard, [ + [0], + [1], + ]); + + const widget1Rect = getElementFromCell(dashboard, 1, 0)!.getBoundingClientRect(); + + // Assign a renderer that initially renders nothing + const syncRenderer = dashboard.renderer!; + dashboard.renderer = (root, _, model) => { + root.textContent = ''; + requestAnimationFrame(() => { + syncRenderer(root, _, model); + }); + }; + await nextFrame(); + await nextFrame(); + + fireResizeStart(getElementFromCell(dashboard, 0, 0)!); + await nextFrame(); + fireResizeOver(getElementFromCell(dashboard, 0, 0)!, 'top'); + await nextFrame(); + + expect(() => { + // Dispatch dragover event while the renderer is still rendering (no widget in the cells) + const x = widget1Rect.left + widget1Rect.width / 2; + const y = widget1Rect.bottom; + const event = new MouseEvent('mousemove', { + bubbles: true, + composed: true, + clientX: x, + clientY: y, + buttons: 1, + }); + dashboard.dispatchEvent(event); + }).to.not.throw(); + }); + + it('should take gap into account when resizing', async () => { + dashboard.style.width = `${columnWidth * 3}px`; + setGap(dashboard, columnWidth / 2); + await nextFrame(); + + // prettier-ignore + expectLayout(dashboard, [ + [0, 1], + ]); + + fireResizeStart(getElementFromCell(dashboard, 0, 0)!); + await nextFrame(); + fireResizeOver(getElementFromCell(dashboard, 0, 1)!, 'start'); + await nextFrame(); + + // prettier-ignore + expectLayout(dashboard, [ + [0, 1], + ]); + }); + + it('should not shrink colspan below 0', async () => { + const resizeSpy = sinon.spy(); + dashboard.addEventListener('dashboard-item-drag-resize', resizeSpy); + + fireResizeStart(getElementFromCell(dashboard, 0, 0)!); + await nextFrame(); + fireResizeOver(getElementFromCell(dashboard, 0, 0)!, 'start'); + await nextFrame(); + + expect((dashboard.items[0] as TestDashboardItem).colspan).to.be.undefined; + expect(resizeSpy).to.not.have.been.calledOnce; + }); + + it('should not shrink rowspan below 0', async () => { + const resizeSpy = sinon.spy(); + dashboard.addEventListener('dashboard-item-drag-resize', resizeSpy); + + fireResizeStart(getElementFromCell(dashboard, 0, 0)!); + await nextFrame(); + fireResizeOver(getElementFromCell(dashboard, 0, 0)!, 'top'); + await nextFrame(); + + expect((dashboard.items[0] as TestDashboardItem).rowspan).to.be.undefined; + expect(resizeSpy).to.not.have.been.calledOnce; + }); + + // Make sure the original resized element is restored in the host. + // Otherwise, "track" event would stop working. + describe('ensure track event', () => { + it('should restore the original resized element in host', async () => { + const originalResizedElement = getElementFromCell(dashboard, 0, 0)!; + fireResizeStart(originalResizedElement); + await nextFrame(); + fireResizeOver(getElementFromCell(dashboard, 0, 1)!, 'end'); + await nextFrame(); + + expect(dashboard.contains(originalResizedElement)).to.be.true; + }); + + it('should remove duplicate elements once resize has ended', async () => { + fireResizeStart(getElementFromCell(dashboard, 0, 0)!); + await nextFrame(); + fireResizeOver(getElementFromCell(dashboard, 0, 1)!, 'end'); + await nextFrame(); + + fireResizeEnd(dashboard); + await nextFrame(); + + // Make sure the original dragged element is removed from the host if it was + // restored. + expect(dashboard.querySelectorAll(`vaadin-dashboard-widget[id='item-0']`).length).to.equal(1); + }); + + it('should not remove resized element with a renderer that reuses same instances', async () => { + const reusedWidgets = [ + fixtureSync(''), + fixtureSync(''), + ]; + dashboard.renderer = (root, _, model) => { + root.textContent = ''; + root.appendChild(reusedWidgets[model.item.id]); + }; + await nextFrame(); + + fireResizeStart(reusedWidgets[0]); + await nextFrame(); + fireResizeOver(reusedWidgets[1], 'end'); + await nextFrame(); + + fireResizeEnd(dashboard); + expect(reusedWidgets[0].isConnected).to.be.true; + }); + }); + }); +}); diff --git a/packages/dashboard/test/helpers.ts b/packages/dashboard/test/helpers.ts index 1e8b53deb0..861faf1c99 100644 --- a/packages/dashboard/test/helpers.ts +++ b/packages/dashboard/test/helpers.ts @@ -46,7 +46,7 @@ function _getElementFromCell(dashboard: HTMLElement, rowIndex: number, columnInd .find( (element) => dashboard.contains(element) && element !== dashboard && element.localName !== 'vaadin-dashboard-section', - )!; + ) as HTMLElement; } /** @@ -69,7 +69,7 @@ export function getRowHeights(dashboard: HTMLElement): number[] { /** * Returns the element at the center of the cell at the given row and column index. */ -export function getElementFromCell(dashboard: HTMLElement, rowIndex: number, columnIndex: number): Element | null { +export function getElementFromCell(dashboard: HTMLElement, rowIndex: number, columnIndex: number): HTMLElement | null { const rowHeights = getRowHeights(dashboard); return _getElementFromCell(dashboard, rowIndex, columnIndex, rowHeights); } @@ -208,12 +208,16 @@ export function fireDragStart(dragStartTarget: Element): TestDragEvent { return event; } -export function fireDragOver(dragOverTarget: Element, location: 'top' | 'bottom' | 'start' | 'end'): TestDragEvent { - const { top, bottom, left, right } = dragOverTarget.getBoundingClientRect(); +function getEventCoordinates(relativeElement: Element, location: 'top' | 'bottom' | 'start' | 'end') { + const { top, bottom, left, right } = relativeElement.getBoundingClientRect(); const y = location === 'top' ? top : bottom; const dir = document.dir; const x = location === 'start' ? (dir === 'rtl' ? right : left) : dir === 'rtl' ? left : right; - const event = createDragEvent('dragover', { x, y }); + return { x, y }; +} + +export function fireDragOver(dragOverTarget: Element, location: 'top' | 'bottom' | 'start' | 'end'): TestDragEvent { + const event = createDragEvent('dragover', getEventCoordinates(dragOverTarget, location)); dragOverTarget.dispatchEvent(event); return event; } @@ -233,3 +237,24 @@ export function fireDrop(dragOverTarget: Element): TestDragEvent { export function resetReorderTimeout(dashboard: HTMLElement): void { (dashboard as any).__widgetReorderController.__reordering = false; } + +export function fireResizeOver(dragOverTarget: Element, location: 'top' | 'bottom' | 'start' | 'end'): void { + const { x, y } = getEventCoordinates(dragOverTarget, location); + const event = new MouseEvent('mousemove', { bubbles: true, composed: true, clientX: x, clientY: y, buttons: 1 }); + dragOverTarget.dispatchEvent(event); +} + +export function fireResizeStart(resizedWidget: Element): void { + let handle = resizedWidget.shadowRoot!.querySelector('.resize-handle'); + if (!handle) { + handle = resizedWidget; + } + const { x, y } = getEventCoordinates(handle, 'bottom'); + handle.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, composed: true, clientX: x, clientY: y })); + // Initiate track + fireResizeOver(resizedWidget, 'top'); +} + +export function fireResizeEnd(dragOverTarget: Element): void { + dragOverTarget.dispatchEvent(new MouseEvent('mouseup', { bubbles: true, composed: true })); +} diff --git a/packages/dashboard/test/typings/dashboard.types.ts b/packages/dashboard/test/typings/dashboard.types.ts index 0489bebbb5..df2601a3b2 100644 --- a/packages/dashboard/test/typings/dashboard.types.ts +++ b/packages/dashboard/test/typings/dashboard.types.ts @@ -5,8 +5,11 @@ import type { Dashboard, DashboardItem, DashboardItemDragReorderEvent, + DashboardItemDragResizeEvent, DashboardItemReorderEndEvent, DashboardItemReorderStartEvent, + DashboardItemResizeEndEvent, + DashboardItemResizeStartEvent, DashboardRenderer, DashboardSectionItem, } from '../../vaadin-dashboard.js'; @@ -57,6 +60,23 @@ narrowedDashboard.addEventListener('dashboard-item-drag-reorder', (event) => { assertType(event.detail.targetIndex); }); +narrowedDashboard.addEventListener('dashboard-item-resize-start', (event) => { + assertType>(event); + assertType(event.detail.item); +}); + +narrowedDashboard.addEventListener('dashboard-item-resize-end', (event) => { + assertType>(event); + assertType(event.detail.item); +}); + +narrowedDashboard.addEventListener('dashboard-item-drag-resize', (event) => { + assertType>(event); + assertType(event.detail.item); + assertType(event.detail.colspan); + assertType(event.detail.rowspan); +}); + /* DashboardLayout */ const layout = document.createElement('vaadin-dashboard-layout'); assertType(layout);