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);