import * as plugins from './00plugins.js'; import { DeesElement, type TemplateResult, property, customElement, html, css, cssManager, state, } from '@design.estate/dees-element'; import * as domtools from '@design.estate/dees-domtools'; import './dees-icon.js'; import { demoFunc } from './dees-dashboardgrid.demo.js'; declare global { interface HTMLElementTagNameMap { 'dees-dashboardgrid': DeesDashboardgrid; } } export interface IDashboardWidget { id: string; x: number; y: number; w: number; h: number; minW?: number; minH?: number; maxW?: number; maxH?: number; content: TemplateResult | string; title?: string; icon?: string; noMove?: boolean; noResize?: boolean; locked?: boolean; autoPosition?: boolean; // Auto-position widget in first available space } @customElement('dees-dashboardgrid') export class DeesDashboardgrid extends DeesElement { // STATIC public static demo = demoFunc; // INSTANCE @property({ type: Array }) public widgets: IDashboardWidget[] = []; @property({ type: Number }) public cellHeight: number = 80; @property({ type: Object }) public margin: number | { top?: number; right?: number; bottom?: number; left?: number } = 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: 'px' | 'em' | 'rem' | 'auto' = 'px'; @property({ type: Boolean }) public rtl: boolean = false; // Right-to-left support @property({ type: Boolean }) public showGridLines: boolean = false; @state() private draggedWidget: IDashboardWidget | null = null; @state() private draggedElement: HTMLElement | null = null; @state() private dragOffsetX: number = 0; @state() private dragOffsetY: number = 0; @state() private dragMouseX: number = 0; @state() private dragMouseY: number = 0; @state() private placeholderPosition: { x: number; y: number } | null = null; @state() private resizingWidget: IDashboardWidget | null = null; @state() private resizeStartW: number = 0; @state() private resizeStartH: number = 0; @state() private resizeStartX: number = 0; @state() private resizeStartY: number = 0; public static styles = [ cssManager.defaultStyles, css` :host { display: block; width: 100%; height: 100%; position: relative; } .grid-container { position: relative; width: 100%; min-height: 400px; box-sizing: border-box; } .grid-widget { position: absolute; will-change: auto; } :host([enableanimation]) .grid-widget { transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); } .grid-widget.dragging { z-index: 1000; transition: none !important; opacity: 0.8; cursor: grabbing; pointer-events: none; will-change: transform; } .grid-widget.placeholder { pointer-events: none; z-index: 1; } .grid-widget.placeholder .widget-content { background: ${cssManager.bdTheme('rgba(59, 130, 246, 0.1)', 'rgba(59, 130, 246, 0.1)')}; border: 2px dashed ${cssManager.bdTheme('#3b82f6', '#3b82f6')}; box-shadow: none; } .grid-widget.resizing { transition: none !important; } .widget-content { position: absolute; left: 0; right: 0; top: 0; bottom: 0; overflow: hidden; background: ${cssManager.bdTheme('#ffffff', '#09090b')}; border: 1px solid ${cssManager.bdTheme('#e5e7eb', '#27272a')}; border-radius: 8px; box-shadow: ${cssManager.bdTheme( '0 1px 3px rgba(0, 0, 0, 0.1)', '0 1px 3px rgba(0, 0, 0, 0.3)' )}; transition: box-shadow 0.2s ease; } .grid-widget:hover .widget-content { box-shadow: ${cssManager.bdTheme( '0 4px 12px rgba(0, 0, 0, 0.15)', '0 4px 12px rgba(0, 0, 0, 0.4)' )}; } .grid-widget.dragging .widget-content { box-shadow: ${cssManager.bdTheme( '0 16px 48px rgba(0, 0, 0, 0.25)', '0 16px 48px rgba(0, 0, 0, 0.6)' )}; transform: scale(1.05); } .widget-header { padding: 12px 16px; border-bottom: 1px solid ${cssManager.bdTheme('#e5e7eb', '#27272a')}; display: flex; align-items: center; gap: 8px; font-size: 14px; font-weight: 600; color: ${cssManager.bdTheme('#09090b', '#fafafa')}; background: ${cssManager.bdTheme('#f9fafb', '#0a0a0a')}; cursor: grab; user-select: none; } .widget-header:hover { background: ${cssManager.bdTheme('#f4f4f5', '#18181b')}; } .widget-header:active { cursor: grabbing; } .widget-header.locked { cursor: default; } .widget-header.locked:hover { background: ${cssManager.bdTheme('#f9fafb', '#0a0a0a')}; } .widget-header dees-icon { font-size: 16px; color: ${cssManager.bdTheme('#71717a', '#71717a')}; } .widget-body { position: absolute; top: 0; left: 0; right: 0; bottom: 0; overflow: auto; color: ${cssManager.bdTheme('#09090b', '#fafafa')}; } .widget-body.has-header { top: 45px; } /* Resize handles */ .resize-handle { position: absolute; background: transparent; z-index: 10; } .resize-handle:hover { background: ${cssManager.bdTheme('#3b82f6', '#3b82f6')}; opacity: 0.3; } .resize-handle-e { cursor: ew-resize; width: 12px; right: -6px; top: 10%; height: 80%; } .resize-handle-s { cursor: ns-resize; height: 12px; width: 80%; bottom: -6px; left: 10%; } .resize-handle-se { cursor: se-resize; width: 20px; height: 20px; right: -2px; bottom: -2px; opacity: 0; transition: opacity 0.2s ease; } .resize-handle-se::after { content: ''; position: absolute; right: 4px; bottom: 4px; width: 6px; height: 6px; border-right: 2px solid ${cssManager.bdTheme('#71717a', '#71717a')}; border-bottom: 2px solid ${cssManager.bdTheme('#71717a', '#71717a')}; } .grid-widget:hover .resize-handle-se { opacity: 0.7; } .resize-handle-se:hover { opacity: 1 !important; } .resize-handle-se:hover::after { border-color: ${cssManager.bdTheme('#3b82f6', '#3b82f6')}; } /* Placeholder */ .grid-placeholder { position: absolute; background: ${cssManager.bdTheme('#3b82f6', '#3b82f6')}; opacity: 0.1; border-radius: 8px; border: 2px dashed ${cssManager.bdTheme('#3b82f6', '#3b82f6')}; transition: all 0.2s ease; pointer-events: none; } /* Empty state */ .empty-state { display: flex; flex-direction: column; align-items: center; justify-content: center; height: 400px; color: ${cssManager.bdTheme('#71717a', '#71717a')}; text-align: center; padding: 32px; } .empty-state dees-icon { font-size: 48px; margin-bottom: 16px; opacity: 0.5; } /* Grid lines */ .grid-lines { position: absolute; top: 0; left: 0; right: 0; bottom: 0; pointer-events: none; z-index: -1; } .grid-line-vertical { position: absolute; top: 0; bottom: 0; width: 1px; background: ${cssManager.bdTheme('#e5e7eb', '#27272a')}; opacity: 0.3; } .grid-line-horizontal { position: absolute; left: 0; right: 0; height: 1px; background: ${cssManager.bdTheme('#e5e7eb', '#27272a')}; opacity: 0.3; } `, ]; public render(): TemplateResult { if (this.widgets.length === 0) { return html`
No widgets configured
Add widgets to populate the dashboard
`; } const margins = this.getMargins(); const maxY = Math.max(...this.widgets.map(w => w.y + w.h), 4); const cellHeightValue = this.getCellHeight(); const gridHeight = maxY * cellHeightValue + (maxY + 1) * margins.vertical; return html`
${this.showGridLines ? this.renderGridLines(gridHeight) : ''} ${this.widgets.map(widget => this.renderWidget(widget))} ${this.placeholderPosition && this.draggedWidget ? this.renderPlaceholder() : ''}
`; } private renderGridLines(gridHeight: number): TemplateResult { const margins = this.getMargins(); const cellHeightValue = this.getCellHeight(); // Convert margin to percentage for consistent calculation const containerWidth = this.getBoundingClientRect().width; const marginHorizontalPercent = (margins.horizontal / containerWidth) * 100; const cellWidth = (100 - marginHorizontalPercent * (this.columns + 1)) / this.columns; const verticalLines = []; const horizontalLines = []; // Vertical lines for (let i = 0; i <= this.columns; i++) { const left = i * cellWidth + i * marginHorizontalPercent; verticalLines.push(html`
`); } // Horizontal lines const numHorizontalLines = Math.ceil(gridHeight / (cellHeightValue + margins.vertical)); for (let i = 0; i <= numHorizontalLines; i++) { const top = i * cellHeightValue + i * margins.vertical; horizontalLines.push(html`
`); } return html`
${verticalLines} ${horizontalLines}
`; } private renderWidget(widget: IDashboardWidget): TemplateResult { const isDragging = this.draggedWidget?.id === widget.id; const isResizing = this.resizingWidget?.id === widget.id; const isLocked = widget.locked || !this.editable; const margins = this.getMargins(); const cellHeightValue = this.getCellHeight(); // Convert margin to percentage of container width for consistent calculation const containerWidth = this.getBoundingClientRect().width; const marginHorizontalPercent = (margins.horizontal / containerWidth) * 100; const cellWidth = (100 - marginHorizontalPercent * (this.columns + 1)) / this.columns; const left = widget.x * cellWidth + (widget.x + 1) * marginHorizontalPercent; const top = widget.y * cellHeightValue + (widget.y + 1) * margins.vertical; const width = widget.w * cellWidth + (widget.w - 1) * marginHorizontalPercent; const height = widget.h * cellHeightValue + (widget.h - 1) * margins.vertical; // Apply transform when dragging for smooth movement let transform = ''; if (isDragging && this.draggedElement) { const containerRect = this.getBoundingClientRect(); const translateX = this.dragMouseX - containerRect.left - this.dragOffsetX - (left / 100 * containerRect.width); const translateY = this.dragMouseY - containerRect.top - this.dragOffsetY - top; transform = `transform: translate(${translateX}px, ${translateY}px);`; } return html`
${widget.title ? html`
this.startDrag(e, widget) : null} > ${widget.icon ? html`` : ''} ${widget.title}
` : ''}
${widget.content}
${!isLocked && !widget.noResize ? html`
this.startResize(e, widget, 'e')}>
this.startResize(e, widget, 's')}>
this.startResize(e, widget, 'se')}>
` : ''}
`; } private renderPlaceholder(): TemplateResult { if (!this.placeholderPosition || !this.draggedWidget) return html``; const margins = this.getMargins(); const cellHeightValue = this.getCellHeight(); // Convert margin to percentage of container width for consistent calculation const containerWidth = this.getBoundingClientRect().width; const marginHorizontalPercent = (margins.horizontal / containerWidth) * 100; const cellWidth = (100 - marginHorizontalPercent * (this.columns + 1)) / this.columns; const left = this.placeholderPosition.x * cellWidth + (this.placeholderPosition.x + 1) * marginHorizontalPercent; const top = this.placeholderPosition.y * cellHeightValue + (this.placeholderPosition.y + 1) * margins.vertical; const width = this.draggedWidget.w * cellWidth + (this.draggedWidget.w - 1) * marginHorizontalPercent; const height = this.draggedWidget.h * cellHeightValue + (this.draggedWidget.h - 1) * margins.vertical; return html`
`; } private startDrag(e: MouseEvent, widget: IDashboardWidget) { e.preventDefault(); this.draggedWidget = widget; this.draggedElement = (e.currentTarget as HTMLElement).closest('.grid-widget') as HTMLElement; const rect = this.draggedElement.getBoundingClientRect(); this.dragOffsetX = e.clientX - rect.left; this.dragOffsetY = e.clientY - rect.top; // Initialize mouse position this.dragMouseX = e.clientX; this.dragMouseY = e.clientY; // Initialize placeholder at current widget position this.placeholderPosition = { x: widget.x, y: widget.y }; document.addEventListener('mousemove', this.handleDrag); document.addEventListener('mouseup', this.endDrag); this.requestUpdate(); } private handleDrag = (e: MouseEvent) => { if (!this.draggedWidget || !this.draggedElement) return; // Update mouse position for smooth dragging this.dragMouseX = e.clientX; this.dragMouseY = e.clientY; const containerRect = this.getBoundingClientRect(); const margins = this.getMargins(); const cellHeightValue = this.getCellHeight(); // Convert margin to percentage to match renderWidget calculations const marginHorizontalPercent = (margins.horizontal / containerRect.width) * 100; const cellWidthPercent = (100 - marginHorizontalPercent * (this.columns + 1)) / this.columns; const cellWidthPixels = containerRect.width * cellWidthPercent / 100; // Get mouse position relative to grid container const mouseX = e.clientX - containerRect.left - this.dragOffsetX; const mouseY = e.clientY - containerRect.top - this.dragOffsetY; // Calculate which cell the mouse is over by finding the closest cell center let gridX = 0; let minDistance = Infinity; // Check distance to center of each possible column position for (let i = 0; i < this.columns; i++) { // Calculate position in pixels (matching renderWidget percentage formula) const leftPercent = i * cellWidthPercent + (i + 1) * marginHorizontalPercent; const cellLeftPixels = containerRect.width * leftPercent / 100; const cellCenterPixels = cellLeftPixels + cellWidthPixels / 2; const distance = Math.abs(mouseX - cellCenterPixels); if (distance < minDistance) { minDistance = distance; gridX = i; } } // For Y: find closest row center let gridY = 0; minDistance = Infinity; // Check reasonable number of rows for (let i = 0; i < 100; i++) { const cellTop = i * cellHeightValue + (i + 1) * margins.vertical; const cellCenter = cellTop + cellHeightValue / 2; const distance = Math.abs(mouseY - cellCenter); if (distance < minDistance) { minDistance = distance; gridY = i; } // Stop checking if we're too far away if (cellTop > mouseY + cellHeightValue) break; } const clampedX = Math.max(0, Math.min(gridX, this.columns - this.draggedWidget.w)); const clampedY = Math.max(0, gridY); // Update placeholder position instead of widget position during drag if (!this.placeholderPosition || clampedX !== this.placeholderPosition.x || clampedY !== this.placeholderPosition.y) { const collision = this.checkCollision(this.draggedWidget, clampedX, clampedY); if (!collision) { this.placeholderPosition = { x: clampedX, y: clampedY }; this.requestUpdate(); } } }; private endDrag = () => { // Apply final position from placeholder if (this.draggedWidget && this.placeholderPosition) { this.draggedWidget.x = this.placeholderPosition.x; this.draggedWidget.y = this.placeholderPosition.y; this.dispatchEvent(new CustomEvent('widget-move', { detail: { widget: this.draggedWidget }, bubbles: true, composed: true, })); } // Clear drag state this.draggedWidget = null; this.draggedElement = null; this.placeholderPosition = null; this.dragMouseX = 0; this.dragMouseY = 0; document.removeEventListener('mousemove', this.handleDrag); document.removeEventListener('mouseup', this.endDrag); this.requestUpdate(); }; private startResize(e: MouseEvent, widget: IDashboardWidget, handle: string) { e.preventDefault(); e.stopPropagation(); this.resizingWidget = widget; this.resizeStartW = widget.w; this.resizeStartH = widget.h; this.resizeStartX = e.clientX; this.resizeStartY = e.clientY; const handleResize = (e: MouseEvent) => { if (!this.resizingWidget) return; const containerRect = this.getBoundingClientRect(); const margins = this.getMargins(); const cellHeightValue = this.getCellHeight(); const cellWidth = (containerRect.width - margins.horizontal * (this.columns + 1)) / this.columns; const deltaX = e.clientX - this.resizeStartX; const deltaY = e.clientY - this.resizeStartY; if (handle.includes('e')) { const newW = Math.round(this.resizeStartW + deltaX / (cellWidth + margins.horizontal)); const maxW = widget.maxW || (this.columns - this.resizingWidget.x); this.resizingWidget.w = Math.max(widget.minW || 1, Math.min(newW, maxW)); } if (handle.includes('s')) { const newH = Math.round(this.resizeStartH + deltaY / (cellHeightValue + margins.vertical)); const maxH = widget.maxH || Infinity; this.resizingWidget.h = Math.max(widget.minH || 1, Math.min(newH, maxH)); } this.requestUpdate(); this.dispatchEvent(new CustomEvent('widget-resize', { detail: { widget: this.resizingWidget }, bubbles: true, composed: true, })); }; const endResize = () => { this.resizingWidget = null; document.removeEventListener('mousemove', handleResize); document.removeEventListener('mouseup', endResize); }; document.addEventListener('mousemove', handleResize); document.addEventListener('mouseup', endResize); } public removeWidget(widgetId: string) { this.widgets = this.widgets.filter(w => w.id !== widgetId); } public updateWidget(widgetId: string, updates: Partial) { this.widgets = this.widgets.map(w => w.id === widgetId ? { ...w, ...updates } : w ); } public getLayout(): Array<{ id: string; x: number; y: number; w: number; h: number }> { return this.widgets.map(({ id, x, y, w, h }) => ({ id, x, y, w, h })); } public setLayout(layout: Array<{ id: string; x: number; y: number; w: number; h: number }>) { this.widgets = this.widgets.map(widget => { const layoutItem = layout.find(l => l.id === widget.id); return layoutItem ? { ...widget, ...layoutItem } : widget; }); } public lockGrid() { this.editable = false; } public unlockGrid() { this.editable = true; } private getMargins(): { horizontal: number; vertical: number; top: number; right: number; bottom: number; left: number } { if (typeof this.margin === 'number') { return { horizontal: this.margin, vertical: this.margin, top: this.margin, right: this.margin, bottom: this.margin, left: this.margin, }; } const margins = { top: this.margin.top ?? 10, right: this.margin.right ?? 10, bottom: this.margin.bottom ?? 10, left: this.margin.left ?? 10, }; return { ...margins, horizontal: (margins.left + margins.right) / 2, vertical: (margins.top + margins.bottom) / 2, }; } private getCellHeight(): number { if (this.cellHeightUnit === 'auto') { // Calculate square cells based on container width const containerWidth = this.getBoundingClientRect().width; const margins = this.getMargins(); const cellWidth = (containerWidth - margins.horizontal * (this.columns + 1)) / this.columns; return cellWidth; } return this.cellHeight; } private checkCollision(widget: IDashboardWidget, newX: number, newY: number): boolean { const widgets = this.widgets.filter(w => w.id !== widget.id); for (const other of widgets) { if (newX < other.x + other.w && newX + widget.w > other.x && newY < other.y + other.h && newY + widget.h > other.y) { return true; } } return false; } public addWidget(widget: IDashboardWidget, autoPosition = false) { if (autoPosition || widget.autoPosition) { // Find first available position const position = this.findAvailablePosition(widget.w, widget.h); widget.x = position.x; widget.y = position.y; } this.widgets = [...this.widgets, widget]; } private findAvailablePosition(width: number, height: number): { x: number; y: number } { // Try to find space starting from top-left for (let y = 0; y < 100; y++) { // Reasonable limit for (let x = 0; x <= this.columns - width; x++) { const testWidget = { id: 'test', x, y, w: width, h: height, content: '' } as IDashboardWidget; if (!this.checkCollision(testWidget, x, y)) { return { x, y }; } } } // If no space found, place at bottom const maxY = Math.max(...this.widgets.map(w => w.y + w.h), 0); return { x: 0, y: maxY }; } public compact(direction: 'vertical' | 'horizontal' = 'vertical') { const sortedWidgets = [...this.widgets].sort((a, b) => { if (direction === 'vertical') { if (a.y !== b.y) return a.y - b.y; return a.x - b.x; } else { if (a.x !== b.x) return a.x - b.x; return a.y - b.y; } }); for (const widget of sortedWidgets) { if (widget.locked || widget.noMove) continue; if (direction === 'vertical') { // Move up as far as possible while (widget.y > 0 && !this.checkCollision(widget, widget.x, widget.y - 1)) { widget.y--; } } else { // Move left as far as possible while (widget.x > 0 && !this.checkCollision(widget, widget.x - 1, widget.y)) { widget.x--; } } } this.requestUpdate(); } }