diff --git a/dev/dashboard.html b/dev/dashboard.html index 1517f2d513..7a69b285b5 100644 --- a/dev/dashboard.html +++ b/dev/dashboard.html @@ -87,10 +87,24 @@ `; }; + + dashboard.addEventListener('dashboard-item-reorder-start', (e) => { + console.log('dashboard-item-reorder-start'); + }); + + dashboard.addEventListener('dashboard-item-drag-reorder', (e) => { + console.log('dashboard-item-drag-reorder', e.detail); + // e.preventDefault(); + }); + + dashboard.addEventListener('dashboard-item-reorder-end', (e) => { + console.log('dashboard-item-reorder-end'); + console.log('items after reorder', e.target.items); + }); - + diff --git a/packages/dashboard/src/vaadin-dashboard-section.js b/packages/dashboard/src/vaadin-dashboard-section.js index 409a2f9926..597fd9bea5 100644 --- a/packages/dashboard/src/vaadin-dashboard-section.js +++ b/packages/dashboard/src/vaadin-dashboard-section.js @@ -15,6 +15,7 @@ import { ElementMixin } from '@vaadin/component-base/src/element-mixin.js'; import { PolylitMixin } from '@vaadin/component-base/src/polylit-mixin.js'; import { css } from '@vaadin/vaadin-themable-mixin/vaadin-themable-mixin.js'; import { TitleController } from './title-controller.js'; +import { dashboardWidgetAndSectionStyles, hasWidgetWrappers } from './vaadin-dashboard-styles.js'; /** * A section component for use with the Dashboard component @@ -30,48 +31,54 @@ class DashboardSection extends ControllerMixin(ElementMixin(PolylitMixin(LitElem } static get styles() { - return css` - :host { - display: grid; - grid-template-columns: subgrid; - --_vaadin-dashboard-section-column: 1 / calc(var(--_vaadin-dashboard-effective-col-count) + 1); - grid-column: var(--_vaadin-dashboard-section-column) !important; - gap: var(--vaadin-dashboard-gap, 1rem); - /* Dashbaord section header height */ - --_vaadin-dashboard-section-header-height: minmax(0, auto); - grid-template-rows: var(--_vaadin-dashboard-section-header-height) repeat( - auto-fill, - var(--_vaadin-dashboard-row-height) - ); - grid-auto-rows: var(--_vaadin-dashboard-row-height); - } - - :host([hidden]) { - display: none !important; - } - - ::slotted(*) { - --_vaadin-dashboard-title-level: 3; - --_vaadin-dashboard-item-column: span - min( - var(--vaadin-dashboard-item-colspan, 1), - var(--_vaadin-dashboard-effective-col-count, var(--_vaadin-dashboard-col-count)) - ); - - grid-column: var(--_vaadin-dashboard-item-column); - } - - ::slotted(vaadin-dashboard-widget-wrapper) { - display: contents; - } - - header { - display: flex; - grid-column: var(--_vaadin-dashboard-section-column); - justify-content: space-between; - align-items: center; - } - `; + return [ + css` + :host { + display: grid; + position: relative; + grid-template-columns: subgrid; + --_vaadin-dashboard-section-column: 1 / calc(var(--_vaadin-dashboard-effective-col-count) + 1); + grid-column: var(--_vaadin-dashboard-section-column) !important; + gap: var(--vaadin-dashboard-gap, 1rem); + /* Dashbaord section header height */ + --_vaadin-dashboard-section-header-height: minmax(0, auto); + grid-template-rows: var(--_vaadin-dashboard-section-header-height) repeat( + auto-fill, + var(--_vaadin-dashboard-row-height) + ); + grid-auto-rows: var(--_vaadin-dashboard-row-height); + } + + :host([hidden]) { + display: none !important; + } + + :host([highlight]) { + background-color: #f5f5f5; + } + + ::slotted(*) { + --_vaadin-dashboard-title-level: 3; + --_vaadin-dashboard-item-column: span + min( + var(--vaadin-dashboard-item-colspan, 1), + var(--_vaadin-dashboard-effective-col-count, var(--_vaadin-dashboard-col-count)) + ); + + grid-column: var(--_vaadin-dashboard-item-column); + } + + header { + grid-column: var(--_vaadin-dashboard-section-column); + } + + :host::before { + z-index: 2 !important; + } + `, + hasWidgetWrappers, + dashboardWidgetAndSectionStyles, + ]; } static get properties() { @@ -92,7 +99,9 @@ class DashboardSection extends ControllerMixin(ElementMixin(PolylitMixin(LitElem return html`
-
+
+ +
diff --git a/packages/dashboard/src/vaadin-dashboard-styles.js b/packages/dashboard/src/vaadin-dashboard-styles.js new file mode 100644 index 0000000000..d874cdc469 --- /dev/null +++ b/packages/dashboard/src/vaadin-dashboard-styles.js @@ -0,0 +1,37 @@ +import { css } from '@vaadin/vaadin-themable-mixin/vaadin-themable-mixin.js'; + +export const hasWidgetWrappers = css` + ::slotted(vaadin-dashboard-widget-wrapper) { + display: contents; + } +`; + +export const dashboardWidgetAndSectionStyles = css` + /* Placeholder shown while the widget or section is dragged */ + :host::before { + content: ''; + z-index: 1; + position: absolute; + display: var(--_vaadin-dashboard-item-placeholder-display, none); + inset: 0; + border: 3px dashed black; + border-radius: 5px; + background-color: #fff; + } + + header { + display: flex; + justify-content: space-between; + align-items: center; + } + + #header-actions { + display: var(--_vaadin-dashboard-widget-actions-display, none); + } + + #drag-handle::before { + font-size: 30px; + content: '☰'; + cursor: grab; + } +`; diff --git a/packages/dashboard/src/vaadin-dashboard-widget.js b/packages/dashboard/src/vaadin-dashboard-widget.js index 661aa40f16..8139e91b0e 100644 --- a/packages/dashboard/src/vaadin-dashboard-widget.js +++ b/packages/dashboard/src/vaadin-dashboard-widget.js @@ -15,6 +15,7 @@ import { ElementMixin } from '@vaadin/component-base/src/element-mixin.js'; import { PolylitMixin } from '@vaadin/component-base/src/polylit-mixin.js'; import { css } from '@vaadin/vaadin-themable-mixin/vaadin-themable-mixin.js'; import { TitleController } from './title-controller.js'; +import { dashboardWidgetAndSectionStyles } from './vaadin-dashboard-styles.js'; /** * A Widget component for use with the Dashboard component @@ -30,27 +31,25 @@ class DashboardWidget extends ControllerMixin(ElementMixin(PolylitMixin(LitEleme } static get styles() { - return css` - :host { - display: flex; - flex-direction: column; - grid-column: var(--_vaadin-dashboard-item-column); - } - - :host([hidden]) { - display: none !important; - } - - header { - display: flex; - justify-content: space-between; - align-items: center; - } - - #content { - flex: 1; - } - `; + return [ + css` + :host { + display: flex; + flex-direction: column; + grid-column: var(--_vaadin-dashboard-item-column); + position: relative; + } + + :host([hidden]) { + display: none !important; + } + + #content { + flex: 1; + } + `, + dashboardWidgetAndSectionStyles, + ]; } static get properties() { @@ -72,7 +71,9 @@ 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 3308b5cc32..4d535d5652 100644 --- a/packages/dashboard/src/vaadin-dashboard.d.ts +++ b/packages/dashboard/src/vaadin-dashboard.d.ts @@ -24,7 +24,7 @@ export interface DashboardSectionItem { /** * The title of the section */ - title: string | null | undefined; + title?: string | null; /** * The items of the section @@ -42,6 +42,34 @@ export type DashboardRenderer = ( model: DashboardItemModel, ) => void; +/** + * Fired when item reordering starts + */ +export type DashboardItemReorderStartEvent = CustomEvent; + +/** + * Fired when item reordering ends + */ +export type DashboardItemReorderEndEvent = CustomEvent; + +/** + * Fired when an items will be reordered by dragging + */ +export type DashboardItemDragReorderEvent = CustomEvent<{ + item: TItem | DashboardSectionItem; + targetIndex: number; +}>; + +export interface DashboardCustomEventMap { + 'dashboard-item-reorder-start': DashboardItemReorderStartEvent; + + 'dashboard-item-reorder-end': DashboardItemReorderEndEvent; + + 'dashboard-item-drag-reorder': DashboardItemDragReorderEvent; +} + +export type DashboardEventMap = DashboardCustomEventMap & HTMLElementEventMap; + /** * A responsive, grid-based dashboard layout component */ @@ -65,6 +93,23 @@ declare class Dashboard extends Das * - `model.item` The item. */ renderer: DashboardRenderer | null | undefined; + + /** + * Whether the dashboard is editable. + */ + editable: boolean; + + addEventListener>( + type: K, + listener: (this: Dashboard, ev: DashboardEventMap[K]) => void, + options?: AddEventListenerOptions | boolean, + ): void; + + removeEventListener>( + type: K, + listener: (this: Dashboard, ev: DashboardEventMap[K]) => void, + options?: EventListenerOptions | boolean, + ): void; } declare global { diff --git a/packages/dashboard/src/vaadin-dashboard.js b/packages/dashboard/src/vaadin-dashboard.js index 8bbb2cba00..5d256acd9c 100644 --- a/packages/dashboard/src/vaadin-dashboard.js +++ b/packages/dashboard/src/vaadin-dashboard.js @@ -11,22 +11,29 @@ import './vaadin-dashboard-widget.js'; import './vaadin-dashboard-section.js'; import { html, LitElement, render } from 'lit'; +import { ControllerMixin } from '@vaadin/component-base/src/controller-mixin.js'; import { defineCustomElement } from '@vaadin/component-base/src/define.js'; import { ElementMixin } from '@vaadin/component-base/src/element-mixin.js'; import { PolylitMixin } from '@vaadin/component-base/src/polylit-mixin.js'; import { css, ThemableMixin } from '@vaadin/vaadin-themable-mixin/vaadin-themable-mixin.js'; import { DashboardLayoutMixin } from './vaadin-dashboard-layout-mixin.js'; +import { hasWidgetWrappers } from './vaadin-dashboard-styles.js'; +import { WidgetReorderController } from './widget-reorder-controller.js'; /** * A responsive, grid-based dashboard layout component * + * @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 + * * @customElement * @extends HTMLElement * @mixes ElementMixin * @mixes DashboardLayoutMixin * @mixes ThemableMixin */ -class Dashboard extends DashboardLayoutMixin(ElementMixin(ThemableMixin(PolylitMixin(LitElement)))) { +class Dashboard extends ControllerMixin(DashboardLayoutMixin(ElementMixin(ThemableMixin(PolylitMixin(LitElement))))) { static get is() { return 'vaadin-dashboard'; } @@ -39,10 +46,11 @@ class Dashboard extends DashboardLayoutMixin(ElementMixin(ThemableMixin(PolylitM return [ super.styles, css` - ::slotted(vaadin-dashboard-widget-wrapper) { - display: contents; + :host([editable]) { + --_vaadin-dashboard-widget-actions-display: block; } `, + hasWidgetWrappers, ]; } @@ -72,6 +80,14 @@ class Dashboard extends DashboardLayoutMixin(ElementMixin(ThemableMixin(PolylitM renderer: { type: Function, }, + + /** + * Whether the dashboard is editable. + */ + editable: { + type: Boolean, + reflectToAttribute: true, + }, }; } @@ -79,6 +95,17 @@ class Dashboard extends DashboardLayoutMixin(ElementMixin(ThemableMixin(PolylitM return ['__itemsOrRendererChanged(items, renderer)']; } + constructor() { + super(); + this.__widgetReorderController = new WidgetReorderController(this); + } + + /** @protected */ + ready() { + super.ready(); + this.addController(this.__widgetReorderController); + } + /** @protected */ render() { return html`
`; @@ -89,6 +116,9 @@ class Dashboard extends DashboardLayoutMixin(ElementMixin(ThemableMixin(PolylitM render(this.__renderItemCells(items || []), this); this.querySelectorAll('vaadin-dashboard-widget-wrapper').forEach((cell) => { + if (cell.firstElementChild && cell.firstElementChild.localName === 'vaadin-dashboard-section') { + return; + } if (renderer) { renderer(cell, this, { item: cell.__item }); } else { @@ -100,22 +130,45 @@ class Dashboard extends DashboardLayoutMixin(ElementMixin(ThemableMixin(PolylitM /** @private */ __renderItemCells(items) { return items.map((item) => { + const itemDragged = this.__widgetReorderController.draggedItem === item; + const style = ` + ${item.colspan ? `--vaadin-dashboard-item-colspan: ${item.colspan};` : ''} + ${itemDragged ? '--_vaadin-dashboard-item-placeholder-display: block;' : ''} + `.trim(); + if (item.items) { - return html` - ${this.__renderItemCells(item.items)} - `; + return html` + + ${this.__renderItemCells(item.items)} + + `; } - return html``; + return html` + `; }); } + + /** + * Fired when item reordering starts + * + * @event dashboard-item-reorder-start + */ + + /** + * Fired when item reordering ends + * + * @event dashboard-item-reorder-end + */ + + /** + * Fired when an items will be reordered by dragging + * + * @event dashboard-item-drag-reorder + */ } defineCustomElement(Dashboard); diff --git a/packages/dashboard/src/widget-reorder-controller.js b/packages/dashboard/src/widget-reorder-controller.js new file mode 100644 index 0000000000..4e39212446 --- /dev/null +++ b/packages/dashboard/src/widget-reorder-controller.js @@ -0,0 +1,233 @@ +/** + * @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'; +const REORDER_EVENT_TIMEOUT = 200; + +/** + * A controller to widget reordering inside a dashboard. + */ +export class WidgetReorderController extends EventTarget { + constructor(host) { + super(); + this.host = host; + this.draggedElementRemoveObserver = new MutationObserver(() => this.__restoreDraggedElement()); + + host.addEventListener('dragstart', (e) => this.__dragStart(e)); + host.addEventListener('dragend', (e) => this.__dragEnd(e)); + host.addEventListener('dragover', (e) => this.__dragOver(e)); + host.addEventListener('drop', (e) => this.__drop(e)); + } + + /** @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); + + // 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'); + + // Observe the removal of the dragged element from the DOM + this.draggedElementRemoveObserver.observe(this.host, { childList: true, subtree: true }); + + 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); + } + } + } + } + + /** @private */ + __dragEnd() { + if (!this.draggedItem) { + return; + } + + // If the originally dragged element is restored to the DOM (as a direct child of the host), + // to make sure "dragend" event gets dispatched, remove it to avoid duplicates + if (this.__draggedElement.parentElement === this.host) { + this.__draggedElement.remove(); + } + + // Reset the dragged element and item, and re-render to remove the placeholder + this.__draggedElement = null; + this.draggedItem = null; + this.host.items = [...this.host.items]; + + // Disconnect the observer for the dragged element removal + this.draggedElementRemoveObserver.disconnect(); + + // Dispatch the reorder end event + this.host.dispatchEvent(new CustomEvent('dashboard-item-reorder-end')); + } + + /** @private */ + __drop(e) { + if (!this.draggedItem) { + return; + } + e.preventDefault(); + } + + /** + * Returns the element closest to the given coordinates. + * @private + */ + __getClosestElement(elements, x, y) { + return elements.reduce( + (closest, element) => { + const { left, top, width, height } = element.getBoundingClientRect(); + const centerX = left + width / 2; + const centerY = top + height / 2; + const distance = Math.hypot(centerX - x, centerY - y); + + return distance < closest.distance ? { element, distance } : closest; + }, + { element: null, distance: Number.MAX_VALUE }, + ).element; + } + + /** + * Returns true if the dragged element is dragged enough over the target element in + * the direction relative to their positions where x and y are the coordinates + * of the drag event. + * @private + */ + __isDraggedOver(draggedElement, targetElement, x, y) { + const draggedPos = draggedElement.getBoundingClientRect(); + const targetPos = targetElement.getBoundingClientRect(); + if (draggedPos.top >= targetPos.bottom) { + // target is on a row above the dragged widget + return y < targetPos.top + targetPos.height / 2; + } else if (draggedPos.bottom <= targetPos.top) { + // target is on a row below the dragged widget + return y > targetPos.top + targetPos.height / 2; + } else if (draggedPos.left >= targetPos.right) { + // target is on a column to the left of the dragged widget + return x < targetPos.left + targetPos.width / 2; + } else if (draggedPos.right <= targetPos.left) { + // target is on a column to the right of the dragged widget + return x > targetPos.left + targetPos.width / 2; + } + } + + /** @private */ + __getElementItem(element) { + return element.closest(WRAPPER_LOCAL_NAME).__item; + } + + /** + * Returns the elements (widgets or sections) that are candidates for reordering with the + * currently dragged item. Effectively, this is the list of child widgets or sections inside + * the same parent container (host or a section) as the dragged item. + * @private + */ + __getDragContextElements() { + const draggedItemWrapper = [...this.host.querySelectorAll(WRAPPER_LOCAL_NAME)].find( + (el) => el.__item === this.draggedItem, + ); + if (!draggedItemWrapper) { + return []; + } + + const siblingWrappers = [...draggedItemWrapper.parentElement.children].filter( + (el) => el.localName === WRAPPER_LOCAL_NAME, + ); + + return siblingWrappers.map((el) => el.firstElementChild).filter((el) => el); + } + + /** @private */ + __reorderItems(draggedItem, targetIndex) { + const items = this.__getItemsArrayOfItem(draggedItem); + const draggedIndex = items.indexOf(draggedItem); + items.splice(draggedIndex, 1); + items.splice(targetIndex, 0, draggedItem); + this.host.items = [...this.host.items]; + } + + /** + * Returns the array of items that contains the given item. + * Might be the host items or the items of a section. + * @private + */ + __getItemsArrayOfItem(item, items = this.host.items) { + for (const i of items) { + if (i === item) { + return items; + } + if (i.items) { + const result = this.__getItemsArrayOfItem(item, i.items); + if (result) { + return result; + } + } + } + return null; + } + + /** + * The dragged element might be removed from the DOM during the drag operation if + * the widgets get re-rendered. This method restores the dragged element if it's not + * present in the DOM to ensure the dragend event is fired. + * @private + */ + __restoreDraggedElement() { + if (!this.host.contains(this.__draggedElement)) { + this.__draggedElement.style.display = 'none'; + this.host.appendChild(this.__draggedElement); + } + } +} diff --git a/packages/dashboard/test/dashboard-layout.test.ts b/packages/dashboard/test/dashboard-layout.test.ts index 3bee75cfc2..2a918e4167 100644 --- a/packages/dashboard/test/dashboard-layout.test.ts +++ b/packages/dashboard/test/dashboard-layout.test.ts @@ -5,8 +5,8 @@ import '../vaadin-dashboard-section.js'; import type { DashboardLayout } from '../vaadin-dashboard-layout.js'; import type { DashboardSection } from '../vaadin-dashboard-section.js'; import { + expectLayout, getColumnWidths, - getElementFromCell, getRowHeights, getScrollingContainer, setColspan, @@ -17,43 +17,6 @@ import { setMinimumRowHeight, } from './helpers.js'; -/** - * Validates the given grid layout. - * - * This function iterates through a number matrix representing the IDs of - * the items in the layout, and checks if the elements in the corresponding - * cells of the grid match the expected IDs. - * - * For example, the following layout would expect a grid with two columns - * and three rows, where the first row has one element with ID "item-0" spanning - * two columns, and the second row has two elements with IDs "item-1" and "item-2" - * where the first one spans two rows, and the last cell in the third row has - * an element with ID "item-3": - * - * ``` - * [ - * [0, 0], - * [1, 2], - * [1, 3] - * ] - * ``` - */ -function expectLayout(dashboard: DashboardLayout, layout: Array>) { - expect(getRowHeights(dashboard).length).to.eql(layout.length); - expect(getColumnWidths(dashboard).length).to.eql(layout[0].length); - - layout.forEach((row, rowIndex) => { - row.forEach((itemId, columnIndex) => { - const element = getElementFromCell(dashboard, rowIndex, columnIndex); - if (!element) { - expect(itemId).to.be.null; - } else { - expect(element.id).to.equal(`item-${itemId}`); - } - }); - }); -} - describe('dashboard layout', () => { let dashboard: DashboardLayout; let childElements: HTMLElement[]; diff --git a/packages/dashboard/test/dashboard-widget-reordering.test.ts b/packages/dashboard/test/dashboard-widget-reordering.test.ts new file mode 100644 index 0000000000..ada60b6e26 --- /dev/null +++ b/packages/dashboard/test/dashboard-widget-reordering.test.ts @@ -0,0 +1,595 @@ +import { expect } from '@vaadin/chai-plugins'; +import { fixtureSync, nextFrame } from '@vaadin/testing-helpers'; +import sinon from 'sinon'; +import '../vaadin-dashboard.js'; +import type { Dashboard, DashboardItem } from '../vaadin-dashboard.js'; +import { + createDragEvent, + expectLayout, + fireDragEnd, + fireDragOver, + fireDragStart, + fireDrop, + getDraggable, + getElementFromCell, + getParentSection, + resetReorderTimeout, + setGap, + setMaximumColumnWidth, + setMinimumColumnWidth, +} from './helpers.js'; + +type TestDashboardItem = DashboardItem & { id: number }; + +describe('dashboard - widget reordering', () => { + let dashboard: Dashboard; + const columnWidth = 100; + + beforeEach(async () => { + dashboard = fixtureSync(''); + dashboard.style.width = `${columnWidth * 2}px`; + setMinimumColumnWidth(dashboard, columnWidth); + setMaximumColumnWidth(dashboard, columnWidth); + setGap(dashboard, 0); + + 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('drag and drop', () => { + it('should reorder the widgets while dragging (start -> end)', async () => { + // Start dragging the first widget + fireDragStart(getElementFromCell(dashboard, 0, 0)!); + await nextFrame(); + + // Drag the first widget over the end edge of the second one + fireDragOver(getElementFromCell(dashboard, 0, 1)!, 'end'); + await nextFrame(); + + // Expect the widgets to be reordered + // prettier-ignore + expectLayout(dashboard, [ + [1, 0], + ]); + }); + + it('should not reorder if dragged barely over another widget (start -> end)', async () => { + fireDragStart(getElementFromCell(dashboard, 0, 0)!); + await nextFrame(); + + fireDragOver(getElementFromCell(dashboard, 0, 1)!, 'start'); + await nextFrame(); + + // prettier-ignore + expectLayout(dashboard, [ + [0, 1], + ]); + }); + + it('should reorder the widgets while dragging (end -> start)', async () => { + fireDragStart(getElementFromCell(dashboard, 0, 1)!); + await nextFrame(); + + fireDragOver(getElementFromCell(dashboard, 0, 0)!, 'start'); + await nextFrame(); + + // prettier-ignore + expectLayout(dashboard, [ + [1, 0], + ]); + }); + + it('should not reorder if dragged barely over another widget (end -> start)', async () => { + fireDragStart(getElementFromCell(dashboard, 0, 1)!); + await nextFrame(); + + fireDragOver(getElementFromCell(dashboard, 0, 0)!, 'end'); + await nextFrame(); + + // prettier-ignore + expectLayout(dashboard, [ + [0, 1], + ]); + }); + + it('should reorder the widgets while dragging (top -> bottom)', async () => { + dashboard.style.width = `${columnWidth}px`; + await nextFrame(); + + // prettier-ignore + expectLayout(dashboard, [ + [0], + [1], + ]); + + fireDragStart(getElementFromCell(dashboard, 0, 0)!); + await nextFrame(); + + fireDragOver(getElementFromCell(dashboard, 1, 0)!, 'bottom'); + await nextFrame(); + + // prettier-ignore + expectLayout(dashboard, [ + [1], + [0], + ]); + }); + + it('should not reorder if dragged barely over another widget (top -> bottom)', async () => { + dashboard.style.width = `${columnWidth}px`; + await nextFrame(); + + // prettier-ignore + expectLayout(dashboard, [ + [0], + [1], + ]); + + fireDragStart(getElementFromCell(dashboard, 0, 0)!); + await nextFrame(); + + fireDragOver(getElementFromCell(dashboard, 1, 0)!, 'top'); + await nextFrame(); + + // prettier-ignore + expectLayout(dashboard, [ + [0], + [1], + ]); + }); + + it('should reorder the widgets while dragging (bottom -> top)', async () => { + dashboard.style.width = `${columnWidth}px`; + await nextFrame(); + + // prettier-ignore + expectLayout(dashboard, [ + [0], + [1], + ]); + + fireDragStart(getElementFromCell(dashboard, 1, 0)!); + await nextFrame(); + + fireDragOver(getElementFromCell(dashboard, 0, 0)!, 'top'); + await nextFrame(); + + // prettier-ignore + expectLayout(dashboard, [ + [1], + [0], + ]); + }); + + it('should not reorder if dragged barely over another widget (bottom -> top)', async () => { + dashboard.style.width = `${columnWidth}px`; + await nextFrame(); + + // prettier-ignore + expectLayout(dashboard, [ + [0], + [1], + ]); + + fireDragStart(getElementFromCell(dashboard, 1, 0)!); + await nextFrame(); + + fireDragOver(getElementFromCell(dashboard, 0, 0)!, 'bottom'); + await nextFrame(); + + // prettier-ignore + expectLayout(dashboard, [ + [0], + [1], + ]); + }); + + it('should not throw with a lazy renderer while reordering', async () => { + // 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(); + + fireDragStart(getElementFromCell(dashboard, 0, 0)!); + await nextFrame(); + + expect(() => { + // Dispatch dragover event while the renderer is still rendering (no widget in the cells) + const dashboardRect = dashboard.getBoundingClientRect(); + const dragOverEvent = createDragEvent('dragover', { + x: dashboardRect.right - dashboardRect.width / 4, + y: dashboardRect.top + dashboardRect.height / 2, + }); + dashboard.dispatchEvent(dragOverEvent); + }).to.not.throw(); + + await nextFrame(); + + // Expect no changes in the layout + // prettier-ignore + expectLayout(dashboard, [ + [0, 1], + ]); + }); + + it('should set the widget as the drag image', async () => { + const draggedElement = getElementFromCell(dashboard, 0, 0)!; + const draggableRect = getDraggable(draggedElement).getBoundingClientRect(); + const setDragImage = fireDragStart(draggedElement).dataTransfer.setDragImage; + await nextFrame(); + + expect(setDragImage).to.have.been.calledOnce; + expect(setDragImage.getCall(0).args[0]).to.equal(draggedElement); + expect(setDragImage.getCall(0).args[1]).to.equal(draggableRect.left + draggableRect.width / 2); + expect(setDragImage.getCall(0).args[2]).to.equal(draggableRect.top + draggableRect.height / 2); + }); + + it('should dispatch an item reorder start event', async () => { + const reorderStartSpy = sinon.spy(); + dashboard.addEventListener('dashboard-item-reorder-start', reorderStartSpy); + fireDragStart(getElementFromCell(dashboard, 0, 0)!); + await nextFrame(); + + expect(reorderStartSpy).to.have.been.calledOnce; + }); + + it('should set data transfer data on drag start', async () => { + const event = fireDragStart(getElementFromCell(dashboard, 0, 0)!); + await nextFrame(); + + expect(event.dataTransfer.getData('text/plain')).to.be.ok; + }); + + it('should not throw when dragging something inside the widgets', () => { + const widget = getElementFromCell(dashboard, 0, 0)!; + const widgetContent = widget.querySelector('.content')!; + expect(() => + widgetContent.dispatchEvent(new DragEvent('dragstart', { bubbles: true, composed: true })), + ).to.not.throw(); + }); + + it('should cancel the dragover event', async () => { + fireDragStart(getElementFromCell(dashboard, 0, 0)!); + const event = fireDragOver(getElementFromCell(dashboard, 0, 1)!, 'end'); + await nextFrame(); + + expect(event.defaultPrevented).to.be.true; + }); + + it('should not cancel the dragover event if drag has not started', async () => { + const event = fireDragOver(getElementFromCell(dashboard, 0, 1)!, 'end'); + await nextFrame(); + + expect(event.defaultPrevented).to.be.false; + }); + + it('should set the dropEffect as move', async () => { + fireDragStart(getElementFromCell(dashboard, 0, 0)!); + const event = fireDragOver(getElementFromCell(dashboard, 0, 1)!, 'end'); + await nextFrame(); + + expect(event.dataTransfer.dropEffect).to.equal('move'); + }); + + it('should dispatch an item drag reorder event', async () => { + const reorderSpy = sinon.spy(); + dashboard.addEventListener('dashboard-item-drag-reorder', reorderSpy); + fireDragStart(getElementFromCell(dashboard, 0, 0)!); + await nextFrame(); + fireDragOver(getElementFromCell(dashboard, 0, 1)!, 'end'); + await nextFrame(); + + expect(reorderSpy).to.have.been.calledOnce; + expect(reorderSpy.getCall(0).args[0].detail).to.deep.equal({ + item: { id: 0 }, + targetIndex: 1, + }); + }); + + it('should not reorder if the drag reorder event is cancelled', async () => { + dashboard.addEventListener('dashboard-item-drag-reorder', (e) => e.preventDefault()); + fireDragStart(getElementFromCell(dashboard, 0, 0)!); + await nextFrame(); + fireDragOver(getElementFromCell(dashboard, 0, 1)!, 'end'); + await nextFrame(); + // prettier-ignore + expectLayout(dashboard, [ + [0, 1], + ]); + }); + + it('should ignore subsequent dragover events', async () => { + dashboard.addEventListener('dashboard-item-drag-reorder', (e) => e.preventDefault()); + const reorderSpy = sinon.spy(); + dashboard.addEventListener('dashboard-item-drag-reorder', reorderSpy); + fireDragStart(getElementFromCell(dashboard, 0, 0)!); + await nextFrame(); + fireDragOver(getElementFromCell(dashboard, 0, 1)!, 'end'); + fireDragOver(getElementFromCell(dashboard, 0, 1)!, 'end'); + await nextFrame(); + + expect(reorderSpy).to.have.been.calledOnce; + }); + + it('should reorder in the app logic', async () => { + dashboard.addEventListener('dashboard-item-drag-reorder', (e) => { + const { item, targetIndex } = e.detail; + const items = dashboard.items.filter((i) => i !== item); + items.splice(targetIndex, 0, item); + dashboard.items = items; + }); + fireDragStart(getElementFromCell(dashboard, 0, 0)!); + await nextFrame(); + fireDragOver(getElementFromCell(dashboard, 0, 1)!, 'end'); + await nextFrame(); + // prettier-ignore + expectLayout(dashboard, [ + [1, 0], + ]); + }); + + it('should not ignore subsequent dragover events after a short timeout', () => { + const clock = sinon.useFakeTimers(); + + dashboard.addEventListener('dashboard-item-drag-reorder', (e) => e.preventDefault()); + const reorderSpy = sinon.spy(); + dashboard.addEventListener('dashboard-item-drag-reorder', reorderSpy); + fireDragStart(getElementFromCell(dashboard, 0, 0)!); + + fireDragOver(getElementFromCell(dashboard, 0, 1)!, 'end'); + clock.tick(500); + fireDragOver(getElementFromCell(dashboard, 0, 1)!, 'end'); + clock.restore(); + + expect(reorderSpy).to.have.been.calledTwice; + }); + + it('should dispatch an item reorder end event', async () => { + const reorderEndSpy = sinon.spy(); + dashboard.addEventListener('dashboard-item-reorder-end', reorderEndSpy); + fireDragStart(getElementFromCell(dashboard, 0, 0)!); + await nextFrame(); + fireDragEnd(dashboard); + await nextFrame(); + + expect(reorderEndSpy).to.have.been.calledOnce; + }); + + it('should not dispatch an item reorder end event if drag has not started', async () => { + const reorderEndSpy = sinon.spy(); + dashboard.addEventListener('dashboard-item-reorder-end', reorderEndSpy); + fireDragEnd(dashboard); + await nextFrame(); + + expect(reorderEndSpy).to.not.have.been.called; + }); + + it('should reorder the element closest to the drag event coordinates', async () => { + // Add a third widget + dashboard.style.width = `${columnWidth * 3}px`; + dashboard.items = [...dashboard.items, { id: 2 }]; + await nextFrame(); + // prettier-ignore + expectLayout(dashboard, [ + [0, 1, 2], + ]); + + // Start dragging the first widget + fireDragStart(getElementFromCell(dashboard, 0, 0)!); + await nextFrame(); + + // Drag the first widget over the end edge of the third one + fireDragOver(getElementFromCell(dashboard, 0, 2)!, 'end'); + await nextFrame(); + + // Expect the widgets to be reordered + // prettier-ignore + expectLayout(dashboard, [ + [1, 2, 0], + ]); + }); + + it('should cancel drop event', async () => { + fireDragStart(getElementFromCell(dashboard, 0, 0)!); + const event = fireDrop(getElementFromCell(dashboard, 0, 1)!); + await nextFrame(); + + expect(event.defaultPrevented).to.be.true; + }); + + it('should not cancel drop event if drag has not started', async () => { + const event = fireDrop(getElementFromCell(dashboard, 0, 1)!); + await nextFrame(); + + expect(event.defaultPrevented).to.be.false; + }); + + it('should allow changs to items while dragging', async () => { + // Start dragging the first widget + fireDragStart(getElementFromCell(dashboard, 0, 0)!); + await nextFrame(); + + // Drag over the second widget to reorder + fireDragOver(getElementFromCell(dashboard, 0, 1)!, 'end'); + await nextFrame(); + + // prettier-ignore + expectLayout(dashboard, [ + [1, 0], + ]); + + // Add a new widget after widget 1 + dashboard.items = [dashboard.items[0], { id: 2 }, dashboard.items[1]]; + await nextFrame(); + + // prettier-ignore + expectLayout(dashboard, [ + [1, 2], + [0], + ]); + + // Drag over the new widget 2 + resetReorderTimeout(dashboard); + fireDragOver(getElementFromCell(dashboard, 0, 1)!, 'top'); + await nextFrame(); + + // prettier-ignore + expectLayout(dashboard, [ + [1, 0], + [2], + ]); + + // Remove the dragged widget + dashboard.items = [dashboard.items[0], dashboard.items[2]]; + await nextFrame(); + + // prettier-ignore + expectLayout(dashboard, [ + [1, 2], + ]); + + // Try dragging over the remaining widgets + resetReorderTimeout(dashboard); + fireDragOver(getElementFromCell(dashboard, 0, 1)!, 'end'); + await nextFrame(); + + resetReorderTimeout(dashboard); + fireDragOver(getElementFromCell(dashboard, 0, 0)!, 'start'); + await nextFrame(); + + fireDragEnd(dashboard); + await nextFrame(); + + // prettier-ignore + expectLayout(dashboard, [ + [1, 2], + ]); + }); + + describe('sections', () => { + beforeEach(async () => { + dashboard.items = [{ id: 0 }, { id: 1 }, { items: [{ id: 2 }, { id: 3 }] }]; + await nextFrame(); + + // prettier-ignore + expectLayout(dashboard, [ + [0, 1], + [2, 3], + ]); + }); + + it('should reorder the widgets inside a section', async () => { + fireDragStart(getElementFromCell(dashboard, 1, 0)!); + await nextFrame(); + + fireDragOver(getElementFromCell(dashboard, 1, 1)!, 'end'); + await nextFrame(); + + // prettier-ignore + expectLayout(dashboard, [ + [0, 1], + [3, 2], + ]); + }); + + it('should reorder the widgets and sections', async () => { + fireDragStart(getElementFromCell(dashboard, 0, 0)!); + await nextFrame(); + + fireDragOver(getElementFromCell(dashboard, 1, 0)!, 'bottom'); + await nextFrame(); + + // prettier-ignore + expectLayout(dashboard, [ + [1, null], + [2, 3], + [0, null], + ]); + }); + + it('should reorder the sections and widgets', async () => { + const section = getParentSection(getElementFromCell(dashboard, 1, 0))!; + fireDragStart(section); + await nextFrame(); + + fireDragOver(getElementFromCell(dashboard, 0, 0)!, 'top'); + await nextFrame(); + + // prettier-ignore + expectLayout(dashboard, [ + [2, 3], + [0, 1], + ]); + }); + }); + + // Make sure the original dragged element is restored in the host. + // Otherwise, "dragend" event would not be fired with native drag and drop. + describe('ensure dragend event', () => { + it('should restore the original dragged element in host', async () => { + const originalDraggedElement = getElementFromCell(dashboard, 0, 0)!; + fireDragStart(originalDraggedElement); + await nextFrame(); + fireDragOver(getElementFromCell(dashboard, 0, 1)!, 'end'); + await nextFrame(); + + expect(dashboard.contains(originalDraggedElement)).to.be.true; + }); + + it('should remove duplicate elements once drag has ended', async () => { + fireDragStart(getElementFromCell(dashboard, 0, 0)!); + await nextFrame(); + fireDragOver(getElementFromCell(dashboard, 0, 1)!, 'end'); + await nextFrame(); + + fireDragEnd(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 dragged 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(); + + fireDragStart(reusedWidgets[0]); + await nextFrame(); + fireDragOver(reusedWidgets[1], 'end'); + await nextFrame(); + + fireDragEnd(dashboard); + expect(reusedWidgets[0].isConnected).to.be.true; + }); + }); + }); +}); diff --git a/packages/dashboard/test/dashboard.test.ts b/packages/dashboard/test/dashboard.test.ts index 0b60830b54..24b75de06d 100644 --- a/packages/dashboard/test/dashboard.test.ts +++ b/packages/dashboard/test/dashboard.test.ts @@ -3,7 +3,7 @@ import { fixtureSync, nextFrame } from '@vaadin/testing-helpers'; import '../vaadin-dashboard.js'; import type { CustomElementType } from '@vaadin/component-base/src/define.js'; import type { Dashboard, DashboardItem } from '../vaadin-dashboard.js'; -import { getElementFromCell, setGap, setMaximumColumnWidth, setMinimumColumnWidth } from './helpers.js'; +import { getDraggable, getElementFromCell, setGap, setMaximumColumnWidth, setMinimumColumnWidth } from './helpers.js'; type TestDashboardItem = DashboardItem & { id: string }; @@ -144,4 +144,20 @@ describe('dashboard', () => { expect(widget3).to.have.property('widgetTitle', 'Item 3 title'); }); }); + + describe('editable', () => { + it('should hide draggable handle by default', () => { + const widget = getElementFromCell(dashboard, 0, 0)!; + const draggable = getDraggable(widget); + expect(draggable.getBoundingClientRect().height).to.equal(0); + }); + + it('should unhide draggable handle when editable', async () => { + dashboard.editable = true; + await nextFrame(); + const widget = getElementFromCell(dashboard, 0, 0)!; + const draggable = getDraggable(widget); + expect(draggable.getBoundingClientRect().height).to.be.above(0); + }); + }); }); diff --git a/packages/dashboard/test/helpers.ts b/packages/dashboard/test/helpers.ts index 75662401ca..f6728340ed 100644 --- a/packages/dashboard/test/helpers.ts +++ b/packages/dashboard/test/helpers.ts @@ -1,3 +1,6 @@ +import { expect } from '@vaadin/chai-plugins'; +import sinon from 'sinon'; + function getCssGrid(element: Element): Element { return (element as any).$?.grid || element; } @@ -9,6 +12,13 @@ export function getScrollingContainer(dashboard: Element): Element { return getCssGrid(dashboard); } +export function getParentSection(element?: Element | null): Element | null { + if (!element) { + return null; + } + return element.closest('vaadin-dashboard-section'); +} + /** * Returns the effective column widths of the dashboard as an array of numbers. */ @@ -46,7 +56,7 @@ export function getRowHeights(dashboard: HTMLElement): number[] { const dashboardRowHeights = _getRowHeights(dashboard); [...dashboardRowHeights].forEach((_height, index) => { const item = _getElementFromCell(dashboard, index, 0, dashboardRowHeights); - const parentSection = item?.closest('vaadin-dashboard-section'); + const parentSection = getParentSection(item); if (parentSection) { const [headerRowHeight, firstRowHeight, ...remainingRowHeights] = _getRowHeights(parentSection); // Merge the first two row heights of the section since the first one is the section header @@ -105,3 +115,114 @@ export function setMaximumColumnCount(dashboard: HTMLElement, count?: number): v export function setMinimumRowHeight(dashboard: HTMLElement, height?: number): void { dashboard.style.setProperty('--vaadin-dashboard-row-min-height', height !== undefined ? `${height}px` : null); } + +/** + * Validates the given grid layout. + * + * This function iterates through a number matrix representing the IDs of + * the items in the layout, and checks if the elements in the corresponding + * cells of the grid match the expected IDs. + * + * For example, the following layout would expect a grid with two columns + * and three rows, where the first row has one element with ID "item-0" spanning + * two columns, and the second row has two elements with IDs "item-1" and "item-2" + * where the first one spans two rows, and the last cell in the third row has + * an element with ID "item-3": + * + * ``` + * [ + * [0, 0], + * [1, 2], + * [1, 3] + * ] + * ``` + */ +export function expectLayout(dashboard: HTMLElement, layout: Array>): void { + expect(getRowHeights(dashboard).length).to.eql(layout.length); + expect(getColumnWidths(dashboard).length).to.eql(layout[0].length); + + layout.forEach((row, rowIndex) => { + row.forEach((itemId, columnIndex) => { + const element = getElementFromCell(dashboard, rowIndex, columnIndex); + if (!element) { + expect(itemId).to.be.null; + } else { + expect(element.id).to.equal(`item-${itemId}`); + } + }); + }); +} + +export function getDraggable(element: Element): Element { + return element.shadowRoot!.querySelector('[draggable]')!; +} + +type TestDragEvent = Event & { + clientX: number; + clientY: number; + dataTransfer: { + dropEffect?: string; + setDragImage: sinon.SinonSpy; + setData(type: string, data: string): void; + getData(type: string): string; + }; +}; + +export function createDragEvent(type: string, { x, y }: { x: number; y: number }): TestDragEvent { + const event = new Event(type, { + bubbles: true, + cancelable: true, + composed: true, + }) as TestDragEvent; + + event.clientX = x; + event.clientY = y; + + const dragData: Record = {}; + event.dataTransfer = { + setDragImage: sinon.spy(), + setData: (type: string, data: string) => { + dragData[type] = data; + }, + getData: (type: string) => dragData[type], + }; + + return event; +} + +export function fireDragStart(dragStartTarget: Element): TestDragEvent { + const draggable = getDraggable(dragStartTarget); + const draggableRect = draggable.getBoundingClientRect(); + const event = createDragEvent('dragstart', { + x: draggableRect.left + draggableRect.width / 2, + y: draggableRect.top + draggableRect.height / 2, + }); + draggable.dispatchEvent(event); + return event; +} + +export function fireDragOver(dragOverTarget: Element, location: 'top' | 'bottom' | 'start' | 'end'): TestDragEvent { + const { top, bottom, left, right } = dragOverTarget.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 }); + dragOverTarget.dispatchEvent(event); + return event; +} + +export function fireDragEnd(dashboard: Element): TestDragEvent { + const event = createDragEvent('dragend', { x: 0, y: 0 }); + dashboard.dispatchEvent(event); + return event; +} + +export function fireDrop(dragOverTarget: Element): TestDragEvent { + const event = createDragEvent('drop', { x: 0, y: 0 }); + dragOverTarget.dispatchEvent(event); + return event; +} + +export function resetReorderTimeout(dashboard: HTMLElement): void { + (dashboard as any).__widgetReorderController.__reordering = false; +} diff --git a/packages/dashboard/test/typings/dashboard.types.ts b/packages/dashboard/test/typings/dashboard.types.ts index 67fc4769e1..6e61a5c9c7 100644 --- a/packages/dashboard/test/typings/dashboard.types.ts +++ b/packages/dashboard/test/typings/dashboard.types.ts @@ -1,7 +1,15 @@ import type { ElementMixinClass } from '@vaadin/component-base/src/element-mixin.js'; import { TitleController } from '../../src/title-controller.js'; import type { DashboardLayoutMixinClass } from '../../src/vaadin-dashboard-layout-mixin.js'; -import type { Dashboard, DashboardItem, DashboardRenderer, DashboardSectionItem } from '../../vaadin-dashboard.js'; +import type { + Dashboard, + DashboardItem, + DashboardItemDragReorderEvent, + DashboardItemReorderEndEvent, + DashboardItemReorderStartEvent, + DashboardRenderer, + DashboardSectionItem, +} from '../../vaadin-dashboard.js'; import type { DashboardLayout } from '../../vaadin-dashboard-layout.js'; import type { DashboardSection } from '../../vaadin-dashboard-section.js'; import type { DashboardWidget } from '../../vaadin-dashboard-widget.js'; @@ -19,6 +27,7 @@ assertType(genericDashboard); assertType(genericDashboard); assertType(genericDashboard); assertType> | null | undefined>(genericDashboard.items); +assertType(genericDashboard.editable); const narrowedDashboard = document.createElement('vaadin-dashboard') as unknown as Dashboard; assertType>(narrowedDashboard); @@ -26,8 +35,24 @@ assertType>>(n assertType | null | undefined>(narrowedDashboard.renderer); assertType< | { colspan?: number; testProperty: string } - | { title: string | null | undefined; items: Array<{ colspan?: number; testProperty: string }> } + | { title?: string | null; items: Array<{ colspan?: number; testProperty: string }> } >(narrowedDashboard.items[0]); + +narrowedDashboard.addEventListener('dashboard-item-reorder-start', (event) => { + assertType(event); +}); + +narrowedDashboard.addEventListener('dashboard-item-reorder-end', (event) => { + assertType(event); +}); + +narrowedDashboard.addEventListener('dashboard-item-drag-reorder', (event) => { + assertType>(event); + assertType>(event.detail.item); + assertType(event.detail.targetIndex); +}); + +/* DashboardLayout */ const layout = document.createElement('vaadin-dashboard-layout'); assertType(layout);