diff --git a/changelog.md b/changelog.md
index cd89601..b2c896d 100644
--- a/changelog.md
+++ b/changelog.md
@@ -1,5 +1,36 @@
# Changelog
+## 2025-06-28 - 1.10.10 - improve(dees-dashboardgrid)
+Enhanced dashboard grid component with advanced spacing and layout features inspired by gridstack.js
+
+- Improved margin system supporting uniform or individual margins (top, right, bottom, left)
+- Added collision detection to prevent widget overlap during drag operations
+- Implemented auto-positioning for new widgets to find first available space
+- Added compact() method to eliminate gaps and compress layout vertically or horizontally
+- Enhanced resize constraints with minW, maxW, minH, maxH support
+- Added optional grid lines visualization for better layout understanding
+- Improved resize handles with better visibility and hover states
+- Added RTL (right-to-left) layout support
+- Implemented cellHeightUnit option supporting 'px', 'em', 'rem', or 'auto' (square cells)
+- Added configurable animation with enableAnimation property
+- Enhanced demo with interactive controls for testing all features
+- Better calculation of widget positions accounting for margins between cells
+- Added findAvailablePosition() for intelligent widget placement
+- Improved drag and resize calculations for pixel-perfect positioning
+
+## 2025-06-28 - 1.10.9 - feat(dees-dashboardgrid)
+Add new dashboard grid component with drag-and-drop and resize capabilities
+
+- Created dees-dashboardgrid component for building flexible dashboard layouts
+- Features drag-and-drop functionality for rearranging widgets
+- Includes resize handles for adjusting widget dimensions
+- Supports configurable grid properties (columns, cell height, gap)
+- Provides widget locking and editable mode controls
+- Styled with shadcn design principles
+- No external dependencies - built with native browser APIs
+- Emits events for widget movements and resizes
+- Includes comprehensive demo with sample dashboard widgets
+
## 2025-06-27 - 1.10.8 - feat(ui-components)
Update multiple components with shadcn-aligned styling and improved animations
diff --git a/ts_web/elements/dees-dashboardgrid.demo.ts b/ts_web/elements/dees-dashboardgrid.demo.ts
new file mode 100644
index 0000000..945ee59
--- /dev/null
+++ b/ts_web/elements/dees-dashboardgrid.demo.ts
@@ -0,0 +1,191 @@
+import { html, css, cssManager } from '@design.estate/dees-element';
+import type { DeesDashboardgrid } from './dees-dashboardgrid.js';
+import '@design.estate/dees-wcctools/demotools';
+
+export const demoFunc = () => {
+ return html`
+ {
+ const grid = elementArg.querySelector('#dashboardGrid') as DeesDashboardgrid;
+
+ // Set initial widgets
+ grid.widgets = [
+ {
+ id: 'metrics1',
+ x: 0,
+ y: 0,
+ w: 3,
+ h: 2,
+ title: 'Revenue',
+ icon: 'lucide:dollarSign',
+ content: html`
+
+
$124,563
+
↑ 12.5% from last month
+
+ `
+ },
+ {
+ id: 'metrics2',
+ x: 3,
+ y: 0,
+ w: 3,
+ h: 2,
+ title: 'Users',
+ icon: 'lucide:users',
+ content: html`
+
+
8,234
+
↑ 5.2% from last week
+
+ `
+ },
+ {
+ id: 'chart1',
+ x: 6,
+ y: 0,
+ w: 6,
+ h: 4,
+ title: 'Analytics',
+ icon: 'lucide:lineChart',
+ content: html`
+
+
+
+
Chart visualization area
+
+
+ `
+ }
+ ];
+
+ // Configure grid
+ grid.cellHeight = 80;
+ grid.margin = { top: 10, right: 10, bottom: 10, left: 10 };
+ grid.enableAnimation = true;
+ grid.showGridLines = false;
+
+ let widgetCounter = 4;
+
+ // Control buttons
+ const buttons = elementArg.querySelectorAll('dees-button');
+ buttons.forEach(button => {
+ const text = button.textContent?.trim();
+
+ if (text === 'Toggle Animation') {
+ button.addEventListener('click', () => {
+ grid.enableAnimation = !grid.enableAnimation;
+ });
+ } else if (text === 'Toggle Grid Lines') {
+ button.addEventListener('click', () => {
+ grid.showGridLines = !grid.showGridLines;
+ });
+ } else if (text === 'Add Widget') {
+ button.addEventListener('click', () => {
+ const newWidget = {
+ id: `widget${widgetCounter++}`,
+ x: 0,
+ y: 0,
+ w: 3,
+ h: 2,
+ autoPosition: true,
+ title: `Widget ${widgetCounter - 1}`,
+ icon: 'lucide:package',
+ content: html`
+
+
New widget content
+
${Math.floor(Math.random() * 1000)}
+
+ `
+ };
+ grid.addWidget(newWidget, true);
+ });
+ } else if (text === 'Compact Grid') {
+ button.addEventListener('click', () => {
+ grid.compact();
+ });
+ } else if (text === 'Toggle Edit Mode') {
+ button.addEventListener('click', () => {
+ grid.editable = !grid.editable;
+ button.textContent = grid.editable ? 'Lock Grid' : 'Unlock Grid';
+ });
+ }
+ });
+
+ // Listen to grid events
+ grid.addEventListener('widget-move', (e: CustomEvent) => {
+ console.log('Widget moved:', e.detail.widget);
+ });
+
+ grid.addEventListener('widget-resize', (e: CustomEvent) => {
+ console.log('Widget resized:', e.detail.widget);
+ });
+ }}>
+
+
+
+
+ Toggle Animation
+
+
+
+ Toggle Grid Lines
+
+
+
+ Add Widget
+ Compact Grid
+
+
+
+ Toggle Edit Mode
+
+
+
+
+
+
+
+
+ Drag widgets to reposition • Resize from edges and corners • Add widgets with auto-positioning
+
+
+
+ `;
+};
\ No newline at end of file
diff --git a/ts_web/elements/dees-dashboardgrid.ts b/ts_web/elements/dees-dashboardgrid.ts
new file mode 100644
index 0000000..a4d2896
--- /dev/null
+++ b/ts_web/elements/dees-dashboardgrid.ts
@@ -0,0 +1,832 @@
+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`
+
+ `;
+ }
+
+ 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();
+ }
+}
\ No newline at end of file
diff --git a/ts_web/elements/index.ts b/ts_web/elements/index.ts
index 87e0f77..cdf29e5 100644
--- a/ts_web/elements/index.ts
+++ b/ts_web/elements/index.ts
@@ -18,6 +18,7 @@ export * from './dees-chips.js';
export * from './dees-contextmenu.js';
export * from './dees-dataview-codebox.js';
export * from './dees-dataview-statusobject.js';
+export * from './dees-dashboardgrid.js';
export * from './dees-editor.js';
export * from './dees-editor-markdown.js';
export * from './dees-editor-markdownoutlet.js';