import { DeesElement, customElement, property, state, html, type TemplateResult, } from '@design.estate/dees-element'; import '../dees-icon.js'; import '../dees-contextmenu.js'; import { demoFunc } from './dees-dashboardgrid.demo.js'; import { dashboardGridStyles } from './styles.js'; import { resolveMargins, calculateCellMetrics, calculateGridHeight, findAvailablePosition, compactLayout, applyLayout, resolveWidgetPlacement, type PlacementResult, } from './layout.js'; import { computeGridCoordinates, computeResizeDimensions, type PointerPosition, } from './interaction.js'; import { openWidgetContextMenu } from './contextmenu.js'; import type { DashboardWidget, DashboardMargin, DashboardResolvedMargins, GridCellMetrics, DashboardLayoutItem, LayoutDirection, CellHeightUnit, } from './types.js'; declare global { interface HTMLElementTagNameMap { 'dees-dashboardgrid': DeesDashboardgrid; } } type DragState = { widgetId: string; pointerId: number; offsetX: number; offsetY: number; start: DashboardLayoutItem; previousPosition: DashboardLayoutItem; currentPointer: PointerPosition; lastPlacement: PlacementResult | null; }; type ResizeState = { widgetId: string; pointerId: number; handler: 'e' | 's' | 'se'; startPointer: PointerPosition; start: DashboardLayoutItem; startWidth: number; startHeight: number; lastPlacement: PlacementResult | null; }; @customElement('dees-dashboardgrid') export class DeesDashboardgrid extends DeesElement { public static demo = demoFunc; public static styles = dashboardGridStyles; @property({ type: Array }) public widgets: DashboardWidget[] = []; @property({ type: Number }) public cellHeight: number = 80; @property({ type: Object }) public margin: DashboardMargin = 10; @property({ type: Number }) public columns: number = 12; @property({ type: Boolean }) public editable: boolean = true; @property({ type: Boolean, reflect: true }) public enableAnimation: boolean = true; @property({ type: String }) public cellHeightUnit: CellHeightUnit = 'px'; @property({ type: Boolean }) public rtl: boolean = false; @property({ type: Boolean }) public showGridLines: boolean = false; @property({ attribute: false }) public layouts?: Record; @property({ type: String }) public activeBreakpoint: string = 'base'; @state() private placeholderPosition: DashboardLayoutItem | null = null; @state() private metrics: GridCellMetrics | null = null; @state() private resolvedMargins: DashboardResolvedMargins | null = null; @state() private previewWidgets: DashboardWidget[] | null = null; private containerBounds: DOMRect | null = null; private dragState: DragState | null = null; private resizeState: ResizeState | null = null; private resizeObserver?: ResizeObserver; private interactionActive = false; public override async connectedCallback(): Promise { await super.connectedCallback(); this.computeMetrics(); this.observeResize(); } public override async disconnectedCallback(): Promise { await super.disconnectedCallback(); this.disconnectResizeObserver(); this.releasePointerEvents(); } protected updated(changed: Map): void { if ( changed.has('margin') || changed.has('columns') || changed.has('cellHeight') || changed.has('cellHeightUnit') ) { this.computeMetrics(); } if (changed.has('widgets') && !this.interactionActive) { this.notifyLayoutChange(); } } public render(): TemplateResult { const baseWidgets = this.widgets; if (baseWidgets.length === 0) { return html`
No widgets configured
Add widgets to populate the dashboard
`; } const metrics = this.ensureMetrics(); const margins = this.resolvedMargins ?? resolveMargins(this.margin); const cellHeight = metrics.cellHeightPx; const layoutForHeight = this.previewWidgets ?? this.widgets; const gridHeight = calculateGridHeight(layoutForHeight, margins, cellHeight); const previewMap = this.previewWidgets ? new Map(this.previewWidgets.map(widget => [widget.id, widget])) : null; return html`
${this.showGridLines ? this.renderGridLines(metrics, gridHeight) : null} ${baseWidgets.map(widget => this.renderWidget(widget, metrics, margins, previewMap))} ${this.placeholderPosition ? this.renderPlaceholder(metrics, margins) : null}
`; } private renderGridLines(metrics: GridCellMetrics, gridHeight: number): TemplateResult { const vertical: TemplateResult[] = []; const horizontal: TemplateResult[] = []; const cellPlusMarginX = metrics.cellWidthPx + metrics.marginHorizontalPx; const cellPlusMarginY = metrics.cellHeightPx + metrics.marginVerticalPx; for (let i = 0; i <= this.columns; i++) { const leftPx = i * cellPlusMarginX + metrics.marginHorizontalPx; const leftPercent = this.pxToPercent(leftPx, metrics.containerWidth); vertical.push(html`
`); } const rows = Math.ceil(gridHeight / cellPlusMarginY); for (let row = 0; row <= rows; row++) { const top = row * cellPlusMarginY; horizontal.push(html`
`); } return html`
${vertical} ${horizontal}
`; } private renderWidget( widget: DashboardWidget, metrics: GridCellMetrics, margins: DashboardResolvedMargins, previewMap: Map | null, ): TemplateResult { const isDragging = this.dragState?.widgetId === widget.id; const isResizing = this.resizeState?.widgetId === widget.id; const isLocked = widget.locked || !this.editable; const previewWidget = previewMap?.get(widget.id) ?? null; const layoutForRender = isDragging ? widget : previewWidget ?? widget; const rect = this.computeWidgetRect(layoutForRender, metrics, margins); const sideProperty = this.rtl ? 'right' : 'left'; const sideValue = this.pxToPercent(rect.left, metrics.containerWidth); const widthPercent = this.pxToPercent(rect.width, metrics.containerWidth); let transform = ''; if (isDragging && this.dragState?.currentPointer) { const pointer = this.dragState.currentPointer; const bounds = this.containerBounds ?? this.getBoundingClientRect(); const translateX = pointer.clientX - bounds.left - this.dragState.offsetX - rect.left; const translateY = pointer.clientY - bounds.top - this.dragState.offsetY - rect.top; transform = `transform: translate(${translateX}px, ${translateY}px);`; } return html`
${widget.title ? html`
this.startDrag(evt, widget) : null} @contextmenu=${(evt: MouseEvent) => this.handleWidgetContextMenu(evt, widget)} tabindex=${!isLocked && !widget.noMove ? 0 : -1} @keydown=${(evt: KeyboardEvent) => this.handleHeaderKeydown(evt, widget)} > ${widget.icon ? html`` : null} ${widget.title}
` : null}
${widget.content}
${!isLocked && !widget.noResize ? html`
this.startResize(evt, widget, 'e')} >
this.startResize(evt, widget, 's')} >
this.startResize(evt, widget, 'se')} >
` : null}
`; } private renderPlaceholder( metrics: GridCellMetrics, margins: DashboardResolvedMargins, ): TemplateResult { if (!this.placeholderPosition) { return html``; } const rect = this.computeWidgetRect(this.placeholderPosition, metrics, margins); const sideProperty = this.rtl ? 'right' : 'left'; const sideValue = this.pxToPercent(rect.left, metrics.containerWidth); const widthPercent = this.pxToPercent(rect.width, metrics.containerWidth); return html`
`; } private startDrag(event: PointerEvent, widget: DashboardWidget): void { if (!this.editable || widget.noMove || widget.locked) { return; } event.preventDefault(); event.stopPropagation(); const widgetElement = (event.currentTarget as HTMLElement).closest('.grid-widget') as HTMLElement | null; if (!widgetElement) { return; } const widgetRect = widgetElement.getBoundingClientRect(); this.containerBounds = this.getBoundingClientRect(); this.ensureMetrics(); this.dragState = { widgetId: widget.id, pointerId: event.pointerId, offsetX: event.clientX - widgetRect.left, offsetY: event.clientY - widgetRect.top, start: { id: widget.id, x: widget.x, y: widget.y, w: widget.w, h: widget.h }, previousPosition: { id: widget.id, x: widget.x, y: widget.y, w: widget.w, h: widget.h }, currentPointer: { clientX: event.clientX, clientY: event.clientY }, lastPlacement: null, }; this.interactionActive = true; (event.currentTarget as HTMLElement).setPointerCapture(event.pointerId); document.addEventListener('pointermove', this.handleDragMove); document.addEventListener('pointerup', this.handleDragEnd); this.placeholderPosition = { id: widget.id, x: widget.x, y: widget.y, w: widget.w, h: widget.h }; } private handleDragMove = (event: PointerEvent): void => { if (!this.dragState) return; const metrics = this.ensureMetrics(); const activeWidgets = this.widgets; const widget = activeWidgets.find(item => item.id === this.dragState!.widgetId); if (!widget) return; event.preventDefault(); const previousPosition = this.dragState.previousPosition; const coords = computeGridCoordinates({ pointer: { clientX: event.clientX, clientY: event.clientY }, containerRect: this.containerBounds ?? this.getBoundingClientRect(), metrics, columns: this.columns, widget, rtl: this.rtl, dragOffsetX: this.dragState.offsetX, dragOffsetY: this.dragState.offsetY, }); const placement = resolveWidgetPlacement( activeWidgets, widget.id, { x: coords.x, y: coords.y }, this.columns, previousPosition, ); if (placement) { const updatedWidget = placement.widgets.find(item => item.id === widget.id); this.dragState = { ...this.dragState, currentPointer: { clientX: event.clientX, clientY: event.clientY }, lastPlacement: placement, previousPosition: updatedWidget ? { id: updatedWidget.id, x: updatedWidget.x, y: updatedWidget.y, w: updatedWidget.w, h: updatedWidget.h } : { id: widget.id, x: coords.x, y: coords.y, w: widget.w, h: widget.h }, }; this.previewWidgets = placement.widgets; const previewWidget = placement.widgets.find(item => item.id === widget.id); if (previewWidget) { this.placeholderPosition = { id: previewWidget.id, x: previewWidget.x, y: previewWidget.y, w: previewWidget.w, h: previewWidget.h, }; } else { this.placeholderPosition = { id: widget.id, x: coords.x, y: coords.y, w: widget.w, h: widget.h }; } } else { this.previewWidgets = null; this.placeholderPosition = null; } this.requestUpdate(); }; private handleDragEnd = (event: PointerEvent): void => { const dragState = this.dragState; if (!dragState || event.pointerId !== dragState.pointerId) { return; } const layoutSource = this.widgets; this.previewWidgets = null; // Always validate the final position, don't rely on lastPlacement from drag const target = this.placeholderPosition ?? dragState.start; const placement = resolveWidgetPlacement( layoutSource, dragState.widgetId, { x: target.x, y: target.y }, this.columns, dragState.previousPosition, ); if (placement) { // Verify that the placement doesn't result in overlapping widgets const finalWidget = placement.widgets.find(w => w.id === dragState.widgetId); if (finalWidget) { const hasOverlap = placement.widgets.some(w => { if (w.id === dragState.widgetId) return false; return ( finalWidget.x < w.x + w.w && finalWidget.x + finalWidget.w > w.x && finalWidget.y < w.y + w.h && finalWidget.y + finalWidget.h > w.y ); }); if (!hasOverlap) { this.commitPlacement(placement, dragState.widgetId, 'widget-move'); } else { // Return to start position if overlap detected this.widgets = this.widgets.map(widget => widget.id === dragState.widgetId ? { ...widget, x: dragState.start.x, y: dragState.start.y } : widget, ); } } } else { // Return to start position if no valid placement this.widgets = this.widgets.map(widget => widget.id === dragState.widgetId ? { ...widget, x: dragState.start.x, y: dragState.start.y } : widget, ); } this.placeholderPosition = null; this.dragState = null; this.interactionActive = false; this.releasePointerEvents(); }; private startResize(event: PointerEvent, widget: DashboardWidget, handler: 'e' | 's' | 'se'): void { if (!this.editable || widget.noResize || widget.locked) { return; } event.preventDefault(); event.stopPropagation(); this.ensureMetrics(); this.resizeState = { widgetId: widget.id, pointerId: event.pointerId, handler, startPointer: { clientX: event.clientX, clientY: event.clientY }, start: { id: widget.id, x: widget.x, y: widget.y, w: widget.w, h: widget.h }, startWidth: widget.w, startHeight: widget.h, lastPlacement: null, }; this.interactionActive = true; (event.currentTarget as HTMLElement).setPointerCapture(event.pointerId); document.addEventListener('pointermove', this.handleResizeMove); document.addEventListener('pointerup', this.handleResizeEnd); this.placeholderPosition = { id: widget.id, x: widget.x, y: widget.y, w: widget.w, h: widget.h }; } private handleResizeMove = (event: PointerEvent): void => { if (!this.resizeState) return; const metrics = this.ensureMetrics(); const activeWidgets = this.widgets; const widget = activeWidgets.find(item => item.id === this.resizeState!.widgetId); if (!widget) return; event.preventDefault(); const nextSize = computeResizeDimensions({ pointer: { clientX: event.clientX, clientY: event.clientY }, containerRect: this.containerBounds ?? this.getBoundingClientRect(), metrics, startWidth: this.resizeState.startWidth, startHeight: this.resizeState.startHeight, startPointer: this.resizeState.startPointer, handler: this.resizeState.handler, widget, columns: this.columns, }); const placement = resolveWidgetPlacement( activeWidgets, widget.id, { x: widget.x, y: widget.y, w: nextSize.width, h: nextSize.height }, this.columns, this.resizeState.start, ); if (placement) { this.resizeState = { ...this.resizeState, lastPlacement: placement }; this.previewWidgets = placement.widgets; const previewWidget = placement.widgets.find(item => item.id === widget.id); if (previewWidget) { this.placeholderPosition = { id: previewWidget.id, x: previewWidget.x, y: previewWidget.y, w: previewWidget.w, h: previewWidget.h, }; } else { this.placeholderPosition = { id: widget.id, x: widget.x, y: widget.y, w: nextSize.width, h: nextSize.height, }; } } else { this.previewWidgets = null; this.placeholderPosition = null; } this.requestUpdate(); }; private handleResizeEnd = (event: PointerEvent): void => { const resizeState = this.resizeState; if (!resizeState || event.pointerId !== resizeState.pointerId) { return; } const layoutSource = this.widgets; this.previewWidgets = null; const placement = resizeState.lastPlacement ?? resolveWidgetPlacement( layoutSource, resizeState.widgetId, { x: this.placeholderPosition?.x ?? resizeState.start.x, y: this.placeholderPosition?.y ?? resizeState.start.y, w: this.placeholderPosition?.w ?? resizeState.start.w, h: this.placeholderPosition?.h ?? resizeState.start.h, }, this.columns, resizeState.start, ); if (placement) { this.commitPlacement(placement, resizeState.widgetId, 'widget-resize'); } else { this.widgets = this.widgets.map(widget => widget.id === resizeState.widgetId ? { ...widget, w: resizeState.start.w, h: resizeState.start.h } : widget, ); } this.placeholderPosition = null; this.resizeState = null; this.interactionActive = false; this.releasePointerEvents(); }; private handleHeaderKeydown(event: KeyboardEvent, widget: DashboardWidget): void { if (!this.editable || widget.noMove || widget.locked) { return; } const key = event.key; const isResize = event.shiftKey; let placement: PlacementResult | null = null; if (isResize && ['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'].includes(key)) { event.preventDefault(); const delta = key === 'ArrowRight' || key === 'ArrowDown' ? 1 : -1; if (key === 'ArrowLeft' || key === 'ArrowRight') { const maxWidth = widget.maxW ?? this.columns - widget.x; const nextWidth = Math.max(widget.minW ?? 1, Math.min(maxWidth, widget.w + delta)); placement = resolveWidgetPlacement( this.widgets, widget.id, { x: widget.x, y: widget.y, w: nextWidth, h: widget.h }, this.columns, ); } else { const maxHeight = widget.maxH ?? Number.POSITIVE_INFINITY; const nextHeight = Math.max(widget.minH ?? 1, Math.min(maxHeight, widget.h + delta)); placement = resolveWidgetPlacement( this.widgets, widget.id, { x: widget.x, y: widget.y, w: widget.w, h: nextHeight }, this.columns, ); } if (placement) { this.commitPlacement(placement, widget.id, 'widget-resize'); } return; } const moveMap: Record = { ArrowLeft: { dx: -1, dy: 0 }, ArrowRight: { dx: 1, dy: 0 }, ArrowUp: { dx: 0, dy: -1 }, ArrowDown: { dx: 0, dy: 1 }, }; const delta = moveMap[key]; if (!delta) { return; } event.preventDefault(); const targetX = Math.max(0, Math.min(this.columns - widget.w, widget.x + delta.dx)); const targetY = Math.max(0, widget.y + delta.dy); placement = resolveWidgetPlacement(this.widgets, widget.id, { x: targetX, y: targetY }, this.columns); if (placement) { this.commitPlacement(placement, widget.id, 'widget-move'); } } private handleWidgetContextMenu(event: MouseEvent, widget: DashboardWidget): void { event.preventDefault(); event.stopPropagation(); openWidgetContextMenu({ widget, host: this, event }); } private commitPlacement(result: PlacementResult, widgetId: string, type: 'widget-move' | 'widget-resize'): void { this.previewWidgets = null; this.widgets = result.widgets; const subject = this.widgets.find(item => item.id === widgetId); if (subject) { this.dispatchEvent( new CustomEvent(type, { detail: { widget: subject, displaced: result.movedWidgets.filter(id => id !== widgetId), swappedWith: result.swappedWith, }, bubbles: true, composed: true, }), ); } } public removeWidget(widgetId: string): void { const target = this.widgets.find(widget => widget.id === widgetId); if (!target) return; this.widgets = this.widgets.filter(widget => widget.id !== widgetId); this.dispatchEvent( new CustomEvent('widget-remove', { detail: { widget: target }, bubbles: true, composed: true, }), ); } public updateWidget(widgetId: string, updates: Partial): void { this.widgets = this.widgets.map(widget => (widget.id === widgetId ? { ...widget, ...updates } : widget)); } public getLayout(): DashboardLayoutItem[] { return this.widgets.map(({ id, x, y, w, h }) => ({ id, x, y, w, h })); } public setLayout(layout: DashboardLayoutItem[]): void { this.widgets = applyLayout(this.widgets, layout); } public lockGrid(): void { this.editable = false; } public unlockGrid(): void { this.editable = true; } public addWidget(widget: DashboardWidget, autoPosition = false): void { const nextWidget = { ...widget }; if (autoPosition || nextWidget.autoPosition) { const position = findAvailablePosition(this.widgets, nextWidget.w, nextWidget.h, this.columns); nextWidget.x = position.x; nextWidget.y = position.y; } this.widgets = [...this.widgets, nextWidget]; } public compact(direction: LayoutDirection = 'vertical'): void { const nextWidgets = this.widgets.map(widget => ({ ...widget })); compactLayout(nextWidgets, direction); this.widgets = nextWidgets; } public applyBreakpointLayout(breakpoint: string): void { this.activeBreakpoint = breakpoint; const layout = this.layouts?.[breakpoint]; if (layout) { this.setLayout(layout); } } public notifyLayoutChange(): void { this.dispatchEvent( new CustomEvent('layout-change', { detail: { layout: this.getLayout() }, bubbles: true, composed: true, }), ); } private ensureMetrics(): GridCellMetrics { if (!this.metrics) { this.computeMetrics(); } return this.metrics!; } private computeMetrics(): void { if (!this.isConnected) return; const bounds = this.getBoundingClientRect(); this.containerBounds = bounds; const margins = resolveMargins(this.margin); this.resolvedMargins = margins; this.metrics = calculateCellMetrics(bounds.width, this.columns, margins, this.cellHeight, this.cellHeightUnit); } private observeResize(): void { if (this.resizeObserver) return; this.resizeObserver = new ResizeObserver(() => { this.computeMetrics(); }); this.resizeObserver.observe(this); } private disconnectResizeObserver(): void { this.resizeObserver?.disconnect(); this.resizeObserver = undefined; } private releasePointerEvents(): void { document.removeEventListener('pointermove', this.handleDragMove); document.removeEventListener('pointerup', this.handleDragEnd); document.removeEventListener('pointermove', this.handleResizeMove); document.removeEventListener('pointerup', this.handleResizeEnd); } private pxToPercent(value: number, container: number): number { if (!container) return 0; return Number(((value / container) * 100).toFixed(4)); } private computeWidgetRect( widget: Pick, metrics: GridCellMetrics, margins: DashboardResolvedMargins, ) { const cellWidth = metrics.cellWidthPx; const cellHeight = metrics.cellHeightPx; const left = widget.x * (cellWidth + margins.horizontal) + margins.horizontal; const top = widget.y * (cellHeight + margins.vertical) + margins.vertical; const width = widget.w * cellWidth + Math.max(0, widget.w - 1) * margins.horizontal; const height = widget.h * cellHeight + Math.max(0, widget.h - 1) * margins.vertical; return { left, top, width, height }; } }