feat: implement DeesDashboardgrid component with drag-and-drop functionality
- Added DeesDashboardgrid class for managing a grid of dashboard widgets. - Implemented widget dragging and resizing capabilities. - Introduced layout management with collision detection and margin resolution. - Created styles for grid layout, widget appearance, and animations. - Added support for customizable margins, cell height, and grid lines. - Included methods for adding, removing, and updating widgets dynamically. - Implemented context menu for widget actions and keyboard navigation support. - Established a responsive design with breakpoint handling for different layouts.
This commit is contained in:
		@@ -1,813 +0,0 @@
 | 
				
			|||||||
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`
 | 
					 | 
				
			||||||
        <div class="empty-state">
 | 
					 | 
				
			||||||
          <dees-icon .icon=${'lucide:layoutGrid'}></dees-icon>
 | 
					 | 
				
			||||||
          <div>No widgets configured</div>
 | 
					 | 
				
			||||||
          <div style="font-size: 14px; margin-top: 8px;">Add widgets to populate the dashboard</div>
 | 
					 | 
				
			||||||
        </div>
 | 
					 | 
				
			||||||
      `;
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    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`
 | 
					 | 
				
			||||||
      <div class="grid-container" style="height: ${gridHeight}px;">
 | 
					 | 
				
			||||||
        ${this.showGridLines ? this.renderGridLines(gridHeight) : ''}
 | 
					 | 
				
			||||||
        ${this.widgets.map(widget => this.renderWidget(widget))}
 | 
					 | 
				
			||||||
        ${this.placeholderPosition && this.draggedWidget ? this.renderPlaceholder() : ''}
 | 
					 | 
				
			||||||
      </div>
 | 
					 | 
				
			||||||
    `;
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  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`
 | 
					 | 
				
			||||||
        <div class="grid-line-vertical" style="left: ${left}%;"></div>
 | 
					 | 
				
			||||||
      `);
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
    
 | 
					 | 
				
			||||||
    // 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`
 | 
					 | 
				
			||||||
        <div class="grid-line-horizontal" style="top: ${top}px;"></div>
 | 
					 | 
				
			||||||
      `);
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
    
 | 
					 | 
				
			||||||
    return html`
 | 
					 | 
				
			||||||
      <div class="grid-lines">
 | 
					 | 
				
			||||||
        ${verticalLines}
 | 
					 | 
				
			||||||
        ${horizontalLines}
 | 
					 | 
				
			||||||
      </div>
 | 
					 | 
				
			||||||
    `;
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  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`
 | 
					 | 
				
			||||||
      <div 
 | 
					 | 
				
			||||||
        class="grid-widget ${isDragging ? 'dragging' : ''} ${isResizing ? 'resizing' : ''}"
 | 
					 | 
				
			||||||
        style="
 | 
					 | 
				
			||||||
          ${this.rtl ? 'right' : 'left'}: ${left}%;
 | 
					 | 
				
			||||||
          top: ${top}px;
 | 
					 | 
				
			||||||
          width: ${width}%;
 | 
					 | 
				
			||||||
          height: ${height}px;
 | 
					 | 
				
			||||||
          ${transform}
 | 
					 | 
				
			||||||
        "
 | 
					 | 
				
			||||||
        data-widget-id="${widget.id}"
 | 
					 | 
				
			||||||
      >
 | 
					 | 
				
			||||||
        <div class="widget-content">
 | 
					 | 
				
			||||||
          ${widget.title ? html`
 | 
					 | 
				
			||||||
            <div 
 | 
					 | 
				
			||||||
              class="widget-header ${isLocked ? 'locked' : ''}"
 | 
					 | 
				
			||||||
              @mousedown=${!isLocked && !widget.noMove ? (e: MouseEvent) => this.startDrag(e, widget) : null}
 | 
					 | 
				
			||||||
            >
 | 
					 | 
				
			||||||
              ${widget.icon ? html`<dees-icon .icon=${widget.icon}></dees-icon>` : ''}
 | 
					 | 
				
			||||||
              ${widget.title}
 | 
					 | 
				
			||||||
            </div>
 | 
					 | 
				
			||||||
          ` : ''}
 | 
					 | 
				
			||||||
          <div class="widget-body ${widget.title ? 'has-header' : ''}">
 | 
					 | 
				
			||||||
            ${widget.content}
 | 
					 | 
				
			||||||
          </div>
 | 
					 | 
				
			||||||
          ${!isLocked && !widget.noResize ? html`
 | 
					 | 
				
			||||||
            <div class="resize-handle resize-handle-e" @mousedown=${(e: MouseEvent) => this.startResize(e, widget, 'e')}></div>
 | 
					 | 
				
			||||||
            <div class="resize-handle resize-handle-s" @mousedown=${(e: MouseEvent) => this.startResize(e, widget, 's')}></div>
 | 
					 | 
				
			||||||
            <div class="resize-handle resize-handle-se" @mousedown=${(e: MouseEvent) => this.startResize(e, widget, 'se')}></div>
 | 
					 | 
				
			||||||
          ` : ''}
 | 
					 | 
				
			||||||
        </div>
 | 
					 | 
				
			||||||
      </div>
 | 
					 | 
				
			||||||
    `;
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
  
 | 
					 | 
				
			||||||
  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`
 | 
					 | 
				
			||||||
      <div 
 | 
					 | 
				
			||||||
        class="grid-widget placeholder"
 | 
					 | 
				
			||||||
        style="
 | 
					 | 
				
			||||||
          ${this.rtl ? 'right' : 'left'}: ${left}%;
 | 
					 | 
				
			||||||
          top: ${top}px;
 | 
					 | 
				
			||||||
          width: ${width}%;
 | 
					 | 
				
			||||||
          height: ${height}px;
 | 
					 | 
				
			||||||
        "
 | 
					 | 
				
			||||||
      >
 | 
					 | 
				
			||||||
        <div class="widget-content"></div>
 | 
					 | 
				
			||||||
      </div>
 | 
					 | 
				
			||||||
    `;
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  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();
 | 
					 | 
				
			||||||
    
 | 
					 | 
				
			||||||
    // Get widget position relative to grid container
 | 
					 | 
				
			||||||
    const mouseX = e.clientX - containerRect.left - this.dragOffsetX;
 | 
					 | 
				
			||||||
    const mouseY = e.clientY - containerRect.top - this.dragOffsetY;
 | 
					 | 
				
			||||||
    
 | 
					 | 
				
			||||||
    // Use pixel calculations for accuracy
 | 
					 | 
				
			||||||
    const totalWidth = containerRect.width;
 | 
					 | 
				
			||||||
    const totalMarginWidth = margins.horizontal * (this.columns + 1);
 | 
					 | 
				
			||||||
    const availableWidth = totalWidth - totalMarginWidth;
 | 
					 | 
				
			||||||
    const cellWidthPx = availableWidth / this.columns;
 | 
					 | 
				
			||||||
    
 | 
					 | 
				
			||||||
    // Calculate grid X position
 | 
					 | 
				
			||||||
    // Account for the initial margin and then repeating pattern of cell+margin
 | 
					 | 
				
			||||||
    let gridX = 0;
 | 
					 | 
				
			||||||
    if (mouseX > margins.horizontal) {
 | 
					 | 
				
			||||||
      const adjustedX = mouseX - margins.horizontal;
 | 
					 | 
				
			||||||
      const cellPlusMargin = cellWidthPx + margins.horizontal;
 | 
					 | 
				
			||||||
      gridX = Math.floor(adjustedX / cellPlusMargin + 0.5); // +0.5 for rounding to nearest
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
    
 | 
					 | 
				
			||||||
    // Calculate grid Y position  
 | 
					 | 
				
			||||||
    let gridY = 0;
 | 
					 | 
				
			||||||
    if (mouseY > margins.vertical) {
 | 
					 | 
				
			||||||
      const adjustedY = mouseY - margins.vertical;
 | 
					 | 
				
			||||||
      const cellPlusMargin = cellHeightValue + margins.vertical;
 | 
					 | 
				
			||||||
      gridY = Math.floor(adjustedY / cellPlusMargin + 0.5); // +0.5 for rounding to nearest
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
    
 | 
					 | 
				
			||||||
    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<IDashboardWidget>) {
 | 
					 | 
				
			||||||
    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();
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
							
								
								
									
										47
									
								
								ts_web/elements/dees-dashboardgrid/README.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										47
									
								
								ts_web/elements/dees-dashboardgrid/README.md
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,47 @@
 | 
				
			|||||||
 | 
					# dees-dashboardgrid
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					`<dees-dashboardgrid>` renders a configurable dashboard layout with draggable and resizable tiles. The component is now grouped in its own folder alongside supporting utilities and styles.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					## Key Features
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					- Pointer-driven drag and resize interactions with keyboard fallbacks (arrow keys to move, `Shift` + arrows to resize).
 | 
				
			||||||
 | 
					- Collision-aware placement that swaps compatible tiles or displaces blocking tiles into the next free slot.
 | 
				
			||||||
 | 
					- Context menu (right-click on a tile header) that exposes destructive actions such as tile removal via `dees-contextmenu`.
 | 
				
			||||||
 | 
					- Layout persistence helpers via `getLayout()`, `setLayout(...)`, and the `layout-change` event.
 | 
				
			||||||
 | 
					- Responsive presets through the `layouts` map and `applyBreakpointLayout(...)` helper to hydrate per-breakpoint arrangements.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					## Public API Highlights
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					| Property | Description |
 | 
				
			||||||
 | 
					| --- | --- |
 | 
				
			||||||
 | 
					| `widgets` | Array of tile descriptors (`DashboardWidget`). |
 | 
				
			||||||
 | 
					| `columns` | Number of grid columns. |
 | 
				
			||||||
 | 
					| `layouts` | Optional record of named layout definitions. |
 | 
				
			||||||
 | 
					| `activeBreakpoint` | Name of the currently applied breakpoint layout. |
 | 
				
			||||||
 | 
					| `editable` | Toggles drag/resize affordances. |
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					| Method | Description |
 | 
				
			||||||
 | 
					| --- | --- |
 | 
				
			||||||
 | 
					| `addWidget(widget, autoPosition?)` | Adds a tile, optionally auto-placing it into the next free slot. |
 | 
				
			||||||
 | 
					| `removeWidget(id)` | Removes a tile and emits `widget-remove`. |
 | 
				
			||||||
 | 
					| `applyBreakpointLayout(name)` | Applies a layout from the `layouts` map. |
 | 
				
			||||||
 | 
					| `getLayout()` / `setLayout(layout)` | Retrieve or apply persisted layouts. |
 | 
				
			||||||
 | 
					| `compact(direction?)` | Densifies the grid vertically (default) or horizontally. |
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					| Event | Detail payload |
 | 
				
			||||||
 | 
					| --- | --- |
 | 
				
			||||||
 | 
					| `widget-move` | `{ widget, displaced, swappedWith }` |
 | 
				
			||||||
 | 
					| `widget-resize` | `{ widget, displaced, swappedWith }` |
 | 
				
			||||||
 | 
					| `widget-remove` | `{ widget }` |
 | 
				
			||||||
 | 
					| `layout-change` | `{ layout }` |
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					## Usage Notes
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					- **Right-click** a tile header to open the contextual menu and delete the tile.
 | 
				
			||||||
 | 
					- When resizing, blocking tiles will automatically reflow into free space once the interaction completes.
 | 
				
			||||||
 | 
					- Listen to `layout-change` to persist layouts to storage; rehydrate using `setLayout` or the `layouts` map.
 | 
				
			||||||
 | 
					- For responsive dashboards, populate `grid.layouts = { base: [...], mobile: [...] }` and call `applyBreakpointLayout` based on your own breakpoint logic (see the co-located demo for an example).
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					## Demo
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					The updated `dees-dashboardgrid.demo.ts` showcases live breakpoint switching, layout persistence, and the context menu. Run the demo gallery to explore the interactions end-to-end.
 | 
				
			||||||
							
								
								
									
										29
									
								
								ts_web/elements/dees-dashboardgrid/contextmenu.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										29
									
								
								ts_web/elements/dees-dashboardgrid/contextmenu.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,29 @@
 | 
				
			|||||||
 | 
					import type { DashboardWidget } from './types.js';
 | 
				
			||||||
 | 
					import { DeesContextmenu } from '../dees-contextmenu.js';
 | 
				
			||||||
 | 
					import type { DeesDashboardgrid } from './dees-dashboardgrid.js';
 | 
				
			||||||
 | 
					import * as plugins from '../00plugins.js';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export interface WidgetContextMenuOptions {
 | 
				
			||||||
 | 
					  widget: DashboardWidget;
 | 
				
			||||||
 | 
					  host: DeesDashboardgrid;
 | 
				
			||||||
 | 
					  event: MouseEvent;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export const openWidgetContextMenu = ({
 | 
				
			||||||
 | 
					  widget,
 | 
				
			||||||
 | 
					  host,
 | 
				
			||||||
 | 
					  event,
 | 
				
			||||||
 | 
					}: WidgetContextMenuOptions) => {
 | 
				
			||||||
 | 
					  const items: (plugins.tsclass.website.IMenuItem | { divider: true })[] = [
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					      name: 'Delete tile',
 | 
				
			||||||
 | 
					      iconName: 'lucide:trash2' as any,
 | 
				
			||||||
 | 
					      action: async () => {
 | 
				
			||||||
 | 
					        host.removeWidget(widget.id);
 | 
				
			||||||
 | 
					        return null;
 | 
				
			||||||
 | 
					      },
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					  ];
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  DeesContextmenu.openContextMenuWithOptions(event, items as any);
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
@@ -7,8 +7,7 @@ export const demoFunc = () => {
 | 
				
			|||||||
    <dees-demowrapper .runAfterRender=${async (elementArg: HTMLElement) => {
 | 
					    <dees-demowrapper .runAfterRender=${async (elementArg: HTMLElement) => {
 | 
				
			||||||
      const grid = elementArg.querySelector('#dashboardGrid') as DeesDashboardgrid;
 | 
					      const grid = elementArg.querySelector('#dashboardGrid') as DeesDashboardgrid;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      // Set initial widgets
 | 
					      const seedWidgets = [
 | 
				
			||||||
      grid.widgets = [
 | 
					 | 
				
			||||||
        {
 | 
					        {
 | 
				
			||||||
          id: 'metrics1',
 | 
					          id: 'metrics1',
 | 
				
			||||||
          x: 0,
 | 
					          x: 0,
 | 
				
			||||||
@@ -22,7 +21,7 @@ export const demoFunc = () => {
 | 
				
			|||||||
              <div style="font-size: 32px; font-weight: 700; color: ${cssManager.bdTheme('#09090b', '#fafafa')};">$124,563</div>
 | 
					              <div style="font-size: 32px; font-weight: 700; color: ${cssManager.bdTheme('#09090b', '#fafafa')};">$124,563</div>
 | 
				
			||||||
              <div style="color: #22c55e; font-size: 14px; margin-top: 8px;">↑ 12.5% from last month</div>
 | 
					              <div style="color: #22c55e; font-size: 14px; margin-top: 8px;">↑ 12.5% from last month</div>
 | 
				
			||||||
            </div>
 | 
					            </div>
 | 
				
			||||||
          `
 | 
					          `,
 | 
				
			||||||
        },
 | 
					        },
 | 
				
			||||||
        {
 | 
					        {
 | 
				
			||||||
          id: 'metrics2',
 | 
					          id: 'metrics2',
 | 
				
			||||||
@@ -37,7 +36,7 @@ export const demoFunc = () => {
 | 
				
			|||||||
              <div style="font-size: 32px; font-weight: 700; color: ${cssManager.bdTheme('#09090b', '#fafafa')};">8,234</div>
 | 
					              <div style="font-size: 32px; font-weight: 700; color: ${cssManager.bdTheme('#09090b', '#fafafa')};">8,234</div>
 | 
				
			||||||
              <div style="color: #3b82f6; font-size: 14px; margin-top: 8px;">↑ 5.2% from last week</div>
 | 
					              <div style="color: #3b82f6; font-size: 14px; margin-top: 8px;">↑ 5.2% from last week</div>
 | 
				
			||||||
            </div>
 | 
					            </div>
 | 
				
			||||||
          `
 | 
					          `,
 | 
				
			||||||
        },
 | 
					        },
 | 
				
			||||||
        {
 | 
					        {
 | 
				
			||||||
          id: 'chart1',
 | 
					          id: 'chart1',
 | 
				
			||||||
@@ -54,32 +53,67 @@ export const demoFunc = () => {
 | 
				
			|||||||
                <div>Chart visualization area</div>
 | 
					                <div>Chart visualization area</div>
 | 
				
			||||||
              </div>
 | 
					              </div>
 | 
				
			||||||
            </div>
 | 
					            </div>
 | 
				
			||||||
          `
 | 
					          `,
 | 
				
			||||||
        }
 | 
					        },
 | 
				
			||||||
      ];
 | 
					      ];
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      // Configure grid
 | 
					      grid.widgets = seedWidgets.map(widget => ({ ...widget }));
 | 
				
			||||||
      grid.cellHeight = 80;
 | 
					      grid.cellHeight = 80;
 | 
				
			||||||
      grid.margin = { top: 10, right: 10, bottom: 10, left: 10 };
 | 
					      grid.margin = { top: 10, right: 10, bottom: 10, left: 10 };
 | 
				
			||||||
      grid.enableAnimation = true;
 | 
					      grid.enableAnimation = true;
 | 
				
			||||||
      grid.showGridLines = false;
 | 
					      grid.showGridLines = false;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      const baseLayout = grid.getLayout().map(item => ({ ...item }));
 | 
				
			||||||
 | 
					      const mobileLayout = grid.widgets.map((widget, index) => ({
 | 
				
			||||||
 | 
					        id: widget.id,
 | 
				
			||||||
 | 
					        x: 0,
 | 
				
			||||||
 | 
					        y: index === 0 ? 0 : grid.widgets.slice(0, index).reduce((acc, prev) => acc + prev.h, 0),
 | 
				
			||||||
 | 
					        w: grid.columns,
 | 
				
			||||||
 | 
					        h: widget.h,
 | 
				
			||||||
 | 
					      }));
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      grid.layouts = {
 | 
				
			||||||
 | 
					        base: baseLayout,
 | 
				
			||||||
 | 
					        mobile: mobileLayout,
 | 
				
			||||||
 | 
					      };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      const statusEl = elementArg.querySelector('#dashboardLayoutStatus') as HTMLElement;
 | 
				
			||||||
 | 
					      const updateStatus = () => {
 | 
				
			||||||
 | 
					        const layout = grid.getLayout();
 | 
				
			||||||
 | 
					        statusEl.textContent = `Active breakpoint: ${grid.activeBreakpoint} • Tiles: ${layout.length}`;
 | 
				
			||||||
 | 
					      };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      const mediaQuery = window.matchMedia('(max-width: 768px)');
 | 
				
			||||||
 | 
					      const handleBreakpoint = () => {
 | 
				
			||||||
 | 
					        const target = mediaQuery.matches ? 'mobile' : 'base';
 | 
				
			||||||
 | 
					        grid.applyBreakpointLayout(target);
 | 
				
			||||||
 | 
					        updateStatus();
 | 
				
			||||||
 | 
					      };
 | 
				
			||||||
 | 
					      if ('addEventListener' in mediaQuery) {
 | 
				
			||||||
 | 
					        mediaQuery.addEventListener('change', handleBreakpoint);
 | 
				
			||||||
 | 
					      } else {
 | 
				
			||||||
 | 
					        mediaQuery.addListener(handleBreakpoint);
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					      handleBreakpoint();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      let widgetCounter = 4;
 | 
					      let widgetCounter = 4;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      // Control buttons
 | 
					 | 
				
			||||||
      const buttons = elementArg.querySelectorAll('dees-button');
 | 
					      const buttons = elementArg.querySelectorAll('dees-button');
 | 
				
			||||||
      buttons.forEach(button => {
 | 
					      buttons.forEach(button => {
 | 
				
			||||||
        const text = button.textContent?.trim();
 | 
					        const text = button.textContent?.trim();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        if (text === 'Toggle Animation') {
 | 
					        switch (text) {
 | 
				
			||||||
 | 
					          case 'Toggle Animation':
 | 
				
			||||||
            button.addEventListener('click', () => {
 | 
					            button.addEventListener('click', () => {
 | 
				
			||||||
              grid.enableAnimation = !grid.enableAnimation;
 | 
					              grid.enableAnimation = !grid.enableAnimation;
 | 
				
			||||||
            });
 | 
					            });
 | 
				
			||||||
        } else if (text === 'Toggle Grid Lines') {
 | 
					            break;
 | 
				
			||||||
 | 
					          case 'Toggle Grid Lines':
 | 
				
			||||||
            button.addEventListener('click', () => {
 | 
					            button.addEventListener('click', () => {
 | 
				
			||||||
              grid.showGridLines = !grid.showGridLines;
 | 
					              grid.showGridLines = !grid.showGridLines;
 | 
				
			||||||
            });
 | 
					            });
 | 
				
			||||||
        } else if (text === 'Add Widget') {
 | 
					            break;
 | 
				
			||||||
 | 
					          case 'Add Widget':
 | 
				
			||||||
            button.addEventListener('click', () => {
 | 
					            button.addEventListener('click', () => {
 | 
				
			||||||
              const newWidget = {
 | 
					              const newWidget = {
 | 
				
			||||||
                id: `widget${widgetCounter++}`,
 | 
					                id: `widget${widgetCounter++}`,
 | 
				
			||||||
@@ -93,32 +127,52 @@ export const demoFunc = () => {
 | 
				
			|||||||
                content: html`
 | 
					                content: html`
 | 
				
			||||||
                  <div style="padding: 20px; text-align: center;">
 | 
					                  <div style="padding: 20px; text-align: center;">
 | 
				
			||||||
                    <div style="color: #71717a;">New widget content</div>
 | 
					                    <div style="color: #71717a;">New widget content</div>
 | 
				
			||||||
                  <div style="margin-top: 8px; font-size: 24px; font-weight: 600; color: ${cssManager.bdTheme('#09090b', '#fafafa')};">${Math.floor(Math.random() * 1000)}</div>
 | 
					                    <div style="margin-top: 8px; font-size: 24px; font-weight: 600; color: ${cssManager.bdTheme('#09090b', '#fafafa')};">${Math.floor(
 | 
				
			||||||
 | 
					                      Math.random() * 1000,
 | 
				
			||||||
 | 
					                    )}</div>
 | 
				
			||||||
                  </div>
 | 
					                  </div>
 | 
				
			||||||
              `
 | 
					                `,
 | 
				
			||||||
              };
 | 
					              };
 | 
				
			||||||
              grid.addWidget(newWidget, true);
 | 
					              grid.addWidget(newWidget, true);
 | 
				
			||||||
            });
 | 
					            });
 | 
				
			||||||
        } else if (text === 'Compact Grid') {
 | 
					            break;
 | 
				
			||||||
 | 
					          case 'Compact Grid':
 | 
				
			||||||
            button.addEventListener('click', () => {
 | 
					            button.addEventListener('click', () => {
 | 
				
			||||||
              grid.compact();
 | 
					              grid.compact();
 | 
				
			||||||
            });
 | 
					            });
 | 
				
			||||||
        } else if (text === 'Toggle Edit Mode') {
 | 
					            break;
 | 
				
			||||||
 | 
					          case 'Toggle Edit Mode':
 | 
				
			||||||
            button.addEventListener('click', () => {
 | 
					            button.addEventListener('click', () => {
 | 
				
			||||||
              grid.editable = !grid.editable;
 | 
					              grid.editable = !grid.editable;
 | 
				
			||||||
              button.textContent = grid.editable ? 'Lock Grid' : 'Unlock Grid';
 | 
					              button.textContent = grid.editable ? 'Lock Grid' : 'Unlock Grid';
 | 
				
			||||||
            });
 | 
					            });
 | 
				
			||||||
 | 
					            break;
 | 
				
			||||||
 | 
					          case 'Reset Layout':
 | 
				
			||||||
 | 
					            button.addEventListener('click', () => {
 | 
				
			||||||
 | 
					              grid.applyBreakpointLayout(grid.activeBreakpoint);
 | 
				
			||||||
 | 
					            });
 | 
				
			||||||
 | 
					            break;
 | 
				
			||||||
 | 
					          default:
 | 
				
			||||||
 | 
					            break;
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
      });
 | 
					      });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      // Listen to grid events
 | 
					 | 
				
			||||||
      grid.addEventListener('widget-move', (e: CustomEvent) => {
 | 
					      grid.addEventListener('widget-move', (e: CustomEvent) => {
 | 
				
			||||||
        console.log('Widget moved:', e.detail.widget);
 | 
					        console.log('Widget moved:', e.detail.widget, 'Displaced:', e.detail.displaced);
 | 
				
			||||||
 | 
					      });
 | 
				
			||||||
 | 
					      grid.addEventListener('widget-resize', (e: CustomEvent) => {
 | 
				
			||||||
 | 
					        console.log('Widget resized:', e.detail.widget, 'Displaced:', e.detail.displaced);
 | 
				
			||||||
 | 
					      });
 | 
				
			||||||
 | 
					      grid.addEventListener('widget-remove', (e: CustomEvent) => {
 | 
				
			||||||
 | 
					        console.log('Widget removed:', e.detail.widget);
 | 
				
			||||||
 | 
					        updateStatus();
 | 
				
			||||||
 | 
					      });
 | 
				
			||||||
 | 
					      grid.addEventListener('layout-change', () => {
 | 
				
			||||||
 | 
					        console.log('Layout changed:', grid.getLayout());
 | 
				
			||||||
 | 
					        updateStatus();
 | 
				
			||||||
      });
 | 
					      });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      grid.addEventListener('widget-resize', (e: CustomEvent) => {
 | 
					      updateStatus();
 | 
				
			||||||
        console.log('Widget resized:', e.detail.widget);
 | 
					 | 
				
			||||||
      });
 | 
					 | 
				
			||||||
    }}>
 | 
					    }}>
 | 
				
			||||||
      <style>
 | 
					      <style>
 | 
				
			||||||
        ${css`
 | 
					        ${css`
 | 
				
			||||||
@@ -155,6 +209,14 @@ export const demoFunc = () => {
 | 
				
			|||||||
            font-size: 12px;
 | 
					            font-size: 12px;
 | 
				
			||||||
            font-family: 'Geist Sans', sans-serif;
 | 
					            font-family: 'Geist Sans', sans-serif;
 | 
				
			||||||
            text-align: center;
 | 
					            text-align: center;
 | 
				
			||||||
 | 
					            display: flex;
 | 
				
			||||||
 | 
					            flex-direction: column;
 | 
				
			||||||
 | 
					            gap: 6px;
 | 
				
			||||||
 | 
					          }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					          #dashboardLayoutStatus {
 | 
				
			||||||
 | 
					            font-weight: 600;
 | 
				
			||||||
 | 
					            color: ${cssManager.bdTheme('#3b82f6', '#60a5fa')};
 | 
				
			||||||
          }
 | 
					          }
 | 
				
			||||||
        `}
 | 
					        `}
 | 
				
			||||||
      </style>
 | 
					      </style>
 | 
				
			||||||
@@ -171,6 +233,7 @@ export const demoFunc = () => {
 | 
				
			|||||||
          <dees-button-group label="Actions:">
 | 
					          <dees-button-group label="Actions:">
 | 
				
			||||||
            <dees-button>Add Widget</dees-button>
 | 
					            <dees-button>Add Widget</dees-button>
 | 
				
			||||||
            <dees-button>Compact Grid</dees-button>
 | 
					            <dees-button>Compact Grid</dees-button>
 | 
				
			||||||
 | 
					            <dees-button>Reset Layout</dees-button>
 | 
				
			||||||
          </dees-button-group>
 | 
					          </dees-button-group>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
          <dees-button-group label="Mode:">
 | 
					          <dees-button-group label="Mode:">
 | 
				
			||||||
@@ -183,7 +246,8 @@ export const demoFunc = () => {
 | 
				
			|||||||
        </div>
 | 
					        </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        <div class="info">
 | 
					        <div class="info">
 | 
				
			||||||
          Drag widgets to reposition • Resize from edges and corners • Add widgets with auto-positioning
 | 
					          <div>Drag to reposition, resize from handles, or right-click a header to delete a tile.</div>
 | 
				
			||||||
 | 
					          <div id="dashboardLayoutStatus"></div>
 | 
				
			||||||
        </div>
 | 
					        </div>
 | 
				
			||||||
      </div>
 | 
					      </div>
 | 
				
			||||||
    </dees-demowrapper>
 | 
					    </dees-demowrapper>
 | 
				
			||||||
							
								
								
									
										688
									
								
								ts_web/elements/dees-dashboardgrid/dees-dashboardgrid.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										688
									
								
								ts_web/elements/dees-dashboardgrid/dees-dashboardgrid.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,688 @@
 | 
				
			|||||||
 | 
					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;
 | 
				
			||||||
 | 
					  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<string, DashboardLayoutItem[]>;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @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;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  private containerBounds: DOMRect | null = null;
 | 
				
			||||||
 | 
					  private dragState: DragState | null = null;
 | 
				
			||||||
 | 
					  private resizeState: ResizeState | null = null;
 | 
				
			||||||
 | 
					  private resizeObserver?: ResizeObserver;
 | 
				
			||||||
 | 
					  private interactionActive = false;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  connectedCallback(): void {
 | 
				
			||||||
 | 
					    super.connectedCallback();
 | 
				
			||||||
 | 
					    this.computeMetrics();
 | 
				
			||||||
 | 
					    this.observeResize();
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  disconnectedCallback(): void {
 | 
				
			||||||
 | 
					    super.disconnectedCallback();
 | 
				
			||||||
 | 
					    this.disconnectResizeObserver();
 | 
				
			||||||
 | 
					    this.releasePointerEvents();
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  protected updated(changed: Map<string, unknown>): 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 {
 | 
				
			||||||
 | 
					    if (this.widgets.length === 0) {
 | 
				
			||||||
 | 
					      return html`
 | 
				
			||||||
 | 
					        <div class="empty-state">
 | 
				
			||||||
 | 
					          <dees-icon .icon=${'lucide:layoutGrid'}></dees-icon>
 | 
				
			||||||
 | 
					          <div>No widgets configured</div>
 | 
				
			||||||
 | 
					          <div style="font-size: 14px; margin-top: 8px;">Add widgets to populate the dashboard</div>
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					      `;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    const metrics = this.ensureMetrics();
 | 
				
			||||||
 | 
					    const margins = this.resolvedMargins ?? resolveMargins(this.margin);
 | 
				
			||||||
 | 
					    const cellHeight = metrics.cellHeightPx;
 | 
				
			||||||
 | 
					    const gridHeight = calculateGridHeight(this.widgets, margins, cellHeight);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    return html`
 | 
				
			||||||
 | 
					      <div class="grid-container" style="height: ${gridHeight}px;">
 | 
				
			||||||
 | 
					        ${this.showGridLines ? this.renderGridLines(metrics, gridHeight) : null}
 | 
				
			||||||
 | 
					        ${this.widgets.map(widget => this.renderWidget(widget, metrics, margins))}
 | 
				
			||||||
 | 
					        ${this.placeholderPosition ? this.renderPlaceholder(metrics, margins) : null}
 | 
				
			||||||
 | 
					      </div>
 | 
				
			||||||
 | 
					    `;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  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`<div class="grid-line-vertical" style="left: ${leftPercent}%;"></div>`);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    const rows = Math.ceil(gridHeight / cellPlusMarginY);
 | 
				
			||||||
 | 
					    for (let row = 0; row <= rows; row++) {
 | 
				
			||||||
 | 
					      const top = row * cellPlusMarginY;
 | 
				
			||||||
 | 
					      horizontal.push(html`<div class="grid-line-horizontal" style="top: ${top}px;"></div>`);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    return html`
 | 
				
			||||||
 | 
					      <div class="grid-lines">
 | 
				
			||||||
 | 
					        ${vertical}
 | 
				
			||||||
 | 
					        ${horizontal}
 | 
				
			||||||
 | 
					      </div>
 | 
				
			||||||
 | 
					    `;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  private renderWidget(
 | 
				
			||||||
 | 
					    widget: DashboardWidget,
 | 
				
			||||||
 | 
					    metrics: GridCellMetrics,
 | 
				
			||||||
 | 
					    margins: DashboardResolvedMargins,
 | 
				
			||||||
 | 
					  ): TemplateResult {
 | 
				
			||||||
 | 
					    const isDragging = this.dragState?.widgetId === widget.id;
 | 
				
			||||||
 | 
					    const isResizing = this.resizeState?.widgetId === widget.id;
 | 
				
			||||||
 | 
					    const isLocked = widget.locked || !this.editable;
 | 
				
			||||||
 | 
					    const rect = this.computeWidgetRect(widget, 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`
 | 
				
			||||||
 | 
					      <div
 | 
				
			||||||
 | 
					        class="grid-widget ${isDragging ? 'dragging' : ''} ${isResizing ? 'resizing' : ''}"
 | 
				
			||||||
 | 
					        style="
 | 
				
			||||||
 | 
					          ${sideProperty}: ${sideValue}%;
 | 
				
			||||||
 | 
					          top: ${rect.top}px;
 | 
				
			||||||
 | 
					          width: ${widthPercent}%;
 | 
				
			||||||
 | 
					          height: ${rect.height}px;
 | 
				
			||||||
 | 
					          ${transform}
 | 
				
			||||||
 | 
					        "
 | 
				
			||||||
 | 
					        data-widget-id=${widget.id}
 | 
				
			||||||
 | 
					      >
 | 
				
			||||||
 | 
					        <div class="widget-content">
 | 
				
			||||||
 | 
					          ${widget.title
 | 
				
			||||||
 | 
					            ? html`
 | 
				
			||||||
 | 
					                <div
 | 
				
			||||||
 | 
					                  class="widget-header ${isLocked ? 'locked' : ''}"
 | 
				
			||||||
 | 
					                  @pointerdown=${!isLocked && !widget.noMove
 | 
				
			||||||
 | 
					                    ? (evt: PointerEvent) => 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`<dees-icon .icon=${widget.icon}></dees-icon>` : null}
 | 
				
			||||||
 | 
					                  ${widget.title}
 | 
				
			||||||
 | 
					                </div>
 | 
				
			||||||
 | 
					              `
 | 
				
			||||||
 | 
					            : null}
 | 
				
			||||||
 | 
					          <div class="widget-body ${widget.title ? 'has-header' : ''}">
 | 
				
			||||||
 | 
					            ${widget.content}
 | 
				
			||||||
 | 
					          </div>
 | 
				
			||||||
 | 
					          ${!isLocked && !widget.noResize
 | 
				
			||||||
 | 
					            ? html`
 | 
				
			||||||
 | 
					                <div
 | 
				
			||||||
 | 
					                  class="resize-handle resize-handle-e"
 | 
				
			||||||
 | 
					                  @pointerdown=${(evt: PointerEvent) => this.startResize(evt, widget, 'e')}
 | 
				
			||||||
 | 
					                ></div>
 | 
				
			||||||
 | 
					                <div
 | 
				
			||||||
 | 
					                  class="resize-handle resize-handle-s"
 | 
				
			||||||
 | 
					                  @pointerdown=${(evt: PointerEvent) => this.startResize(evt, widget, 's')}
 | 
				
			||||||
 | 
					                ></div>
 | 
				
			||||||
 | 
					                <div
 | 
				
			||||||
 | 
					                  class="resize-handle resize-handle-se"
 | 
				
			||||||
 | 
					                  @pointerdown=${(evt: PointerEvent) => this.startResize(evt, widget, 'se')}
 | 
				
			||||||
 | 
					                ></div>
 | 
				
			||||||
 | 
					              `
 | 
				
			||||||
 | 
					            : null}
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					      </div>
 | 
				
			||||||
 | 
					    `;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  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`
 | 
				
			||||||
 | 
					      <div
 | 
				
			||||||
 | 
					        class="grid-widget placeholder"
 | 
				
			||||||
 | 
					        style="
 | 
				
			||||||
 | 
					          ${sideProperty}: ${sideValue}%;
 | 
				
			||||||
 | 
					          top: ${rect.top}px;
 | 
				
			||||||
 | 
					          width: ${widthPercent}%;
 | 
				
			||||||
 | 
					          height: ${rect.height}px;
 | 
				
			||||||
 | 
					        "
 | 
				
			||||||
 | 
					      >
 | 
				
			||||||
 | 
					        <div class="widget-content"></div>
 | 
				
			||||||
 | 
					      </div>
 | 
				
			||||||
 | 
					    `;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  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 },
 | 
				
			||||||
 | 
					      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 widget = this.widgets.find(item => item.id === this.dragState!.widgetId);
 | 
				
			||||||
 | 
					    if (!widget) return;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    event.preventDefault();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    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(this.widgets, widget.id, { x: coords.x, y: coords.y }, this.columns);
 | 
				
			||||||
 | 
					    if (placement) {
 | 
				
			||||||
 | 
					      this.dragState = {
 | 
				
			||||||
 | 
					        ...this.dragState,
 | 
				
			||||||
 | 
					        currentPointer: { clientX: event.clientX, clientY: event.clientY },
 | 
				
			||||||
 | 
					        lastPlacement: placement,
 | 
				
			||||||
 | 
					      };
 | 
				
			||||||
 | 
					      this.placeholderPosition = { id: widget.id, x: coords.x, y: coords.y, w: widget.w, h: widget.h };
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    this.requestUpdate();
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  private handleDragEnd = (event: PointerEvent): void => {
 | 
				
			||||||
 | 
					    const dragState = this.dragState;
 | 
				
			||||||
 | 
					    if (!dragState || event.pointerId !== dragState.pointerId) {
 | 
				
			||||||
 | 
					      return;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    const target = this.placeholderPosition ?? dragState.start;
 | 
				
			||||||
 | 
					    const placement =
 | 
				
			||||||
 | 
					      dragState.lastPlacement ??
 | 
				
			||||||
 | 
					      resolveWidgetPlacement(this.widgets, dragState.widgetId, { x: target.x, y: target.y }, this.columns);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if (placement) {
 | 
				
			||||||
 | 
					      this.commitPlacement(placement, dragState.widgetId, 'widget-move');
 | 
				
			||||||
 | 
					    } else {
 | 
				
			||||||
 | 
					      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 widget = this.widgets.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(
 | 
				
			||||||
 | 
					      this.widgets,
 | 
				
			||||||
 | 
					      widget.id,
 | 
				
			||||||
 | 
					      { x: widget.x, y: widget.y, w: nextSize.width, h: nextSize.height },
 | 
				
			||||||
 | 
					      this.columns,
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if (placement) {
 | 
				
			||||||
 | 
					      this.resizeState = { ...this.resizeState, lastPlacement: placement };
 | 
				
			||||||
 | 
					      this.placeholderPosition = { id: widget.id, x: widget.x, y: widget.y, w: nextSize.width, h: nextSize.height };
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    this.requestUpdate();
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  private handleResizeEnd = (event: PointerEvent): void => {
 | 
				
			||||||
 | 
					    const resizeState = this.resizeState;
 | 
				
			||||||
 | 
					    if (!resizeState || event.pointerId !== resizeState.pointerId) {
 | 
				
			||||||
 | 
					      return;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    const placement = resizeState.lastPlacement;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    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<string, { dx: number; dy: number }> = {
 | 
				
			||||||
 | 
					      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.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<DashboardWidget>): 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<DashboardWidget, 'x' | 'y' | 'w' | 'h'>,
 | 
				
			||||||
 | 
					    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 };
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										2
									
								
								ts_web/elements/dees-dashboardgrid/index.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										2
									
								
								ts_web/elements/dees-dashboardgrid/index.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,2 @@
 | 
				
			|||||||
 | 
					export * from './dees-dashboardgrid.js';
 | 
				
			||||||
 | 
					export * from './types.js';
 | 
				
			||||||
							
								
								
									
										105
									
								
								ts_web/elements/dees-dashboardgrid/interaction.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										105
									
								
								ts_web/elements/dees-dashboardgrid/interaction.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,105 @@
 | 
				
			|||||||
 | 
					import type { DashboardWidget, GridCellMetrics } from './types.js';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export interface PointerPosition {
 | 
				
			||||||
 | 
					  clientX: number;
 | 
				
			||||||
 | 
					  clientY: number;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export interface DragComputationArgs {
 | 
				
			||||||
 | 
					  pointer: PointerPosition;
 | 
				
			||||||
 | 
					  containerRect: DOMRect;
 | 
				
			||||||
 | 
					  metrics: GridCellMetrics;
 | 
				
			||||||
 | 
					  columns: number;
 | 
				
			||||||
 | 
					  widget: DashboardWidget;
 | 
				
			||||||
 | 
					  rtl: boolean;
 | 
				
			||||||
 | 
					  dragOffsetX?: number;
 | 
				
			||||||
 | 
					  dragOffsetY?: number;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export const computeGridCoordinates = ({
 | 
				
			||||||
 | 
					  pointer,
 | 
				
			||||||
 | 
					  containerRect,
 | 
				
			||||||
 | 
					  metrics,
 | 
				
			||||||
 | 
					  columns,
 | 
				
			||||||
 | 
					  widget,
 | 
				
			||||||
 | 
					  rtl,
 | 
				
			||||||
 | 
					  dragOffsetX = 0,
 | 
				
			||||||
 | 
					  dragOffsetY = 0,
 | 
				
			||||||
 | 
					}: DragComputationArgs): { x: number; y: number } => {
 | 
				
			||||||
 | 
					  const relativeX = pointer.clientX - containerRect.left - dragOffsetX;
 | 
				
			||||||
 | 
					  const relativeY = pointer.clientY - containerRect.top - dragOffsetY;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const marginX = metrics.marginHorizontalPx;
 | 
				
			||||||
 | 
					  const marginY = metrics.marginVerticalPx;
 | 
				
			||||||
 | 
					  const cellWidth = metrics.cellWidthPx;
 | 
				
			||||||
 | 
					  const cellHeight = metrics.cellHeightPx;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const clamp = (value: number, min: number, max: number) => Math.max(min, Math.min(max, value));
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const adjustedX = clamp(relativeX - marginX, 0, containerRect.width - marginX);
 | 
				
			||||||
 | 
					  const adjustedY = clamp(relativeY - marginY, 0, Number.POSITIVE_INFINITY);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const cellPlusMarginX = cellWidth + marginX;
 | 
				
			||||||
 | 
					  const cellPlusMarginY = cellHeight + marginY;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  let gridX = Math.round(adjustedX / cellPlusMarginX);
 | 
				
			||||||
 | 
					  if (rtl) {
 | 
				
			||||||
 | 
					    gridX = columns - widget.w - gridX;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					  gridX = clamp(gridX, 0, columns - widget.w);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const gridY = clamp(Math.round(adjustedY / cellPlusMarginY), 0, Number.MAX_SAFE_INTEGER);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  return { x: gridX, y: gridY };
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export interface ResizeComputationArgs {
 | 
				
			||||||
 | 
					  pointer: PointerPosition;
 | 
				
			||||||
 | 
					  containerRect: DOMRect;
 | 
				
			||||||
 | 
					  metrics: GridCellMetrics;
 | 
				
			||||||
 | 
					  startWidth: number;
 | 
				
			||||||
 | 
					  startHeight: number;
 | 
				
			||||||
 | 
					  startPointer: PointerPosition;
 | 
				
			||||||
 | 
					  handler: 'e' | 's' | 'se';
 | 
				
			||||||
 | 
					  widget: DashboardWidget;
 | 
				
			||||||
 | 
					  columns: number;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export const computeResizeDimensions = ({
 | 
				
			||||||
 | 
					  pointer,
 | 
				
			||||||
 | 
					  containerRect,
 | 
				
			||||||
 | 
					  metrics,
 | 
				
			||||||
 | 
					  startWidth,
 | 
				
			||||||
 | 
					  startHeight,
 | 
				
			||||||
 | 
					  startPointer,
 | 
				
			||||||
 | 
					  handler,
 | 
				
			||||||
 | 
					  widget,
 | 
				
			||||||
 | 
					  columns,
 | 
				
			||||||
 | 
					}: ResizeComputationArgs): { width: number; height: number } => {
 | 
				
			||||||
 | 
					  const deltaX = pointer.clientX - startPointer.clientX;
 | 
				
			||||||
 | 
					  const deltaY = pointer.clientY - startPointer.clientY;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  let width = startWidth;
 | 
				
			||||||
 | 
					  let height = startHeight;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const cellPlusMarginX = metrics.cellWidthPx + metrics.marginHorizontalPx;
 | 
				
			||||||
 | 
					  const cellPlusMarginY = metrics.cellHeightPx + metrics.marginVerticalPx;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  if (handler.includes('e')) {
 | 
				
			||||||
 | 
					    const deltaCols = Math.round(deltaX / cellPlusMarginX);
 | 
				
			||||||
 | 
					    width = startWidth + deltaCols;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  if (handler.includes('s')) {
 | 
				
			||||||
 | 
					    const deltaRows = Math.round(deltaY / cellPlusMarginY);
 | 
				
			||||||
 | 
					    height = startHeight + deltaRows;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const clampedWidth = Math.max(widget.minW || 1, Math.min(width, widget.maxW || columns - widget.x));
 | 
				
			||||||
 | 
					  const clampedHeight = Math.max(widget.minH || 1, Math.min(height, widget.maxH || Number.MAX_SAFE_INTEGER));
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  return {
 | 
				
			||||||
 | 
					    width: clampedWidth,
 | 
				
			||||||
 | 
					    height: clampedHeight,
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
							
								
								
									
										231
									
								
								ts_web/elements/dees-dashboardgrid/layout.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										231
									
								
								ts_web/elements/dees-dashboardgrid/layout.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,231 @@
 | 
				
			|||||||
 | 
					import type {
 | 
				
			||||||
 | 
					  DashboardResolvedMargins,
 | 
				
			||||||
 | 
					  DashboardMargin,
 | 
				
			||||||
 | 
					  DashboardWidget,
 | 
				
			||||||
 | 
					  DashboardLayoutItem,
 | 
				
			||||||
 | 
					  GridCellMetrics,
 | 
				
			||||||
 | 
					  LayoutDirection,
 | 
				
			||||||
 | 
					} from './types.js';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export const DEFAULT_MARGIN = 10;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export const resolveMargins = (margin: DashboardMargin): DashboardResolvedMargins => {
 | 
				
			||||||
 | 
					  if (typeof margin === 'number') {
 | 
				
			||||||
 | 
					    return {
 | 
				
			||||||
 | 
					      horizontal: margin,
 | 
				
			||||||
 | 
					      vertical: margin,
 | 
				
			||||||
 | 
					      top: margin,
 | 
				
			||||||
 | 
					      right: margin,
 | 
				
			||||||
 | 
					      bottom: margin,
 | 
				
			||||||
 | 
					      left: margin,
 | 
				
			||||||
 | 
					    };
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const resolved = {
 | 
				
			||||||
 | 
					    top: margin.top ?? DEFAULT_MARGIN,
 | 
				
			||||||
 | 
					    right: margin.right ?? DEFAULT_MARGIN,
 | 
				
			||||||
 | 
					    bottom: margin.bottom ?? DEFAULT_MARGIN,
 | 
				
			||||||
 | 
					    left: margin.left ?? DEFAULT_MARGIN,
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  return {
 | 
				
			||||||
 | 
					    ...resolved,
 | 
				
			||||||
 | 
					    horizontal: (resolved.left + resolved.right) / 2,
 | 
				
			||||||
 | 
					    vertical: (resolved.top + resolved.bottom) / 2,
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export const calculateCellMetrics = (
 | 
				
			||||||
 | 
					  containerWidth: number,
 | 
				
			||||||
 | 
					  columns: number,
 | 
				
			||||||
 | 
					  margins: DashboardResolvedMargins,
 | 
				
			||||||
 | 
					  cellHeight: number,
 | 
				
			||||||
 | 
					  cellHeightUnit: string,
 | 
				
			||||||
 | 
					): GridCellMetrics => {
 | 
				
			||||||
 | 
					  const totalMarginWidth = margins.horizontal * (columns + 1);
 | 
				
			||||||
 | 
					  const availableWidth = Math.max(containerWidth - totalMarginWidth, 0);
 | 
				
			||||||
 | 
					  const cellWidthPx = columns > 0 ? availableWidth / columns : 0;
 | 
				
			||||||
 | 
					  const cellHeightPx = cellHeightUnit === 'auto' ? cellWidthPx : cellHeight;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  return {
 | 
				
			||||||
 | 
					    containerWidth,
 | 
				
			||||||
 | 
					    cellWidthPx,
 | 
				
			||||||
 | 
					    marginHorizontalPx: margins.horizontal,
 | 
				
			||||||
 | 
					    cellHeightPx,
 | 
				
			||||||
 | 
					    marginVerticalPx: margins.vertical,
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export const calculateGridHeight = (
 | 
				
			||||||
 | 
					  widgets: DashboardWidget[],
 | 
				
			||||||
 | 
					  margins: DashboardResolvedMargins,
 | 
				
			||||||
 | 
					  cellHeight: number,
 | 
				
			||||||
 | 
					): number => {
 | 
				
			||||||
 | 
					  if (widgets.length === 0) return 0;
 | 
				
			||||||
 | 
					  const maxY = Math.max(...widgets.map(widget => widget.y + widget.h), 0);
 | 
				
			||||||
 | 
					  return maxY * cellHeight + (maxY + 1) * margins.vertical;
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const overlaps = (
 | 
				
			||||||
 | 
					  widget: DashboardWidget,
 | 
				
			||||||
 | 
					  x: number,
 | 
				
			||||||
 | 
					  y: number,
 | 
				
			||||||
 | 
					  w: number,
 | 
				
			||||||
 | 
					  h: number,
 | 
				
			||||||
 | 
					) => x < widget.x + widget.w && x + w > widget.x && y < widget.y + widget.h && y + h > widget.y;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export const collectCollisions = (
 | 
				
			||||||
 | 
					  widgets: DashboardWidget[],
 | 
				
			||||||
 | 
					  target: DashboardWidget,
 | 
				
			||||||
 | 
					  nextX: number,
 | 
				
			||||||
 | 
					  nextY: number,
 | 
				
			||||||
 | 
					  nextW: number = target.w,
 | 
				
			||||||
 | 
					  nextH: number = target.h,
 | 
				
			||||||
 | 
					): DashboardWidget[] => {
 | 
				
			||||||
 | 
					  return widgets.filter(widget => {
 | 
				
			||||||
 | 
					    if (widget.id === target.id) return false;
 | 
				
			||||||
 | 
					    return overlaps(widget, nextX, nextY, nextW, nextH);
 | 
				
			||||||
 | 
					  });
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export const checkCollision = (
 | 
				
			||||||
 | 
					  widgets: DashboardWidget[],
 | 
				
			||||||
 | 
					  target: DashboardWidget,
 | 
				
			||||||
 | 
					  nextX: number,
 | 
				
			||||||
 | 
					  nextY: number,
 | 
				
			||||||
 | 
					): boolean => collectCollisions(widgets, target, nextX, nextY).length > 0;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export const cloneWidget = (widget: DashboardWidget): DashboardWidget => ({ ...widget });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export const cloneWidgets = (widgets: DashboardWidget[]): DashboardWidget[] => widgets.map(cloneWidget);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export const findAvailablePosition = (
 | 
				
			||||||
 | 
					  widgets: DashboardWidget[],
 | 
				
			||||||
 | 
					  width: number,
 | 
				
			||||||
 | 
					  height: number,
 | 
				
			||||||
 | 
					  columns: number,
 | 
				
			||||||
 | 
					): { x: number; y: number } => {
 | 
				
			||||||
 | 
					  for (let y = 0; y < 200; y++) {
 | 
				
			||||||
 | 
					    for (let x = 0; x <= columns - width; x++) {
 | 
				
			||||||
 | 
					      const isFree = !widgets.some(widget => overlaps(widget, x, y, width, height));
 | 
				
			||||||
 | 
					      if (isFree) {
 | 
				
			||||||
 | 
					        return { x, y };
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const maxY = widgets.reduce((acc, widget) => Math.max(acc, widget.y + widget.h), 0);
 | 
				
			||||||
 | 
					  return { x: 0, y: maxY };
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export interface PlacementResult {
 | 
				
			||||||
 | 
					  widgets: DashboardWidget[];
 | 
				
			||||||
 | 
					  movedWidgets: string[];
 | 
				
			||||||
 | 
					  swappedWith?: string;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export const resolveWidgetPlacement = (
 | 
				
			||||||
 | 
					  widgets: DashboardWidget[],
 | 
				
			||||||
 | 
					  widgetId: string,
 | 
				
			||||||
 | 
					  next: { x: number; y: number; w?: number; h?: number },
 | 
				
			||||||
 | 
					  columns: number,
 | 
				
			||||||
 | 
					): PlacementResult | null => {
 | 
				
			||||||
 | 
					  const sourceWidgets = cloneWidgets(widgets);
 | 
				
			||||||
 | 
					  const moving = sourceWidgets.find(widget => widget.id === widgetId);
 | 
				
			||||||
 | 
					  const original = widgets.find(widget => widget.id === widgetId);
 | 
				
			||||||
 | 
					  if (!moving || !original) {
 | 
				
			||||||
 | 
					    return null;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const target = {
 | 
				
			||||||
 | 
					    x: next.x,
 | 
				
			||||||
 | 
					    y: next.y,
 | 
				
			||||||
 | 
					    w: next.w ?? moving.w,
 | 
				
			||||||
 | 
					    h: next.h ?? moving.h,
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  moving.x = target.x;
 | 
				
			||||||
 | 
					  moving.y = target.y;
 | 
				
			||||||
 | 
					  moving.w = target.w;
 | 
				
			||||||
 | 
					  moving.h = target.h;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const collisions = collectCollisions(sourceWidgets, moving, target.x, target.y, target.w, target.h);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  if (collisions.length === 0) {
 | 
				
			||||||
 | 
					    return { widgets: sourceWidgets, movedWidgets: [moving.id] };
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  if (collisions.length === 1) {
 | 
				
			||||||
 | 
					    const other = collisions[0];
 | 
				
			||||||
 | 
					    if (!other.locked && !other.noMove && other.w === moving.w && other.h === moving.h) {
 | 
				
			||||||
 | 
					      const otherClone = sourceWidgets.find(widget => widget.id === other.id);
 | 
				
			||||||
 | 
					      if (otherClone) {
 | 
				
			||||||
 | 
					        otherClone.x = original.x;
 | 
				
			||||||
 | 
					        otherClone.y = original.y;
 | 
				
			||||||
 | 
					        return { widgets: sourceWidgets, movedWidgets: [moving.id, otherClone.id], swappedWith: otherClone.id };
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  // attempt displacement cascade
 | 
				
			||||||
 | 
					  const movedIds = new Set<string>([moving.id]);
 | 
				
			||||||
 | 
					  for (const offending of collisions) {
 | 
				
			||||||
 | 
					    if (offending.locked || offending.noMove) {
 | 
				
			||||||
 | 
					      return null;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    const clone = sourceWidgets.find(widget => widget.id === offending.id);
 | 
				
			||||||
 | 
					    if (!clone) continue;
 | 
				
			||||||
 | 
					    const remaining = sourceWidgets.filter(widget => widget.id !== offending.id);
 | 
				
			||||||
 | 
					    const position = findAvailablePosition(remaining, clone.w, clone.h, columns);
 | 
				
			||||||
 | 
					    clone.x = position.x;
 | 
				
			||||||
 | 
					    clone.y = position.y;
 | 
				
			||||||
 | 
					    movedIds.add(clone.id);
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  // verify no overlaps remain
 | 
				
			||||||
 | 
					  const verify = collectCollisions(sourceWidgets, moving, moving.x, moving.y, moving.w, moving.h);
 | 
				
			||||||
 | 
					  if (verify.length > 0) {
 | 
				
			||||||
 | 
					    return null;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  return { widgets: sourceWidgets, movedWidgets: Array.from(movedIds) };
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export const compactLayout = (
 | 
				
			||||||
 | 
					  widgets: DashboardWidget[],
 | 
				
			||||||
 | 
					  direction: LayoutDirection = 'vertical',
 | 
				
			||||||
 | 
					) => {
 | 
				
			||||||
 | 
					  const sorted = [...widgets].sort((a, b) => {
 | 
				
			||||||
 | 
					    if (direction === 'vertical') {
 | 
				
			||||||
 | 
					      if (a.y !== b.y) return a.y - b.y;
 | 
				
			||||||
 | 
					      return a.x - b.x;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if (a.x !== b.x) return a.x - b.x;
 | 
				
			||||||
 | 
					    return a.y - b.y;
 | 
				
			||||||
 | 
					  });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  for (const widget of sorted) {
 | 
				
			||||||
 | 
					    if (widget.locked || widget.noMove) continue;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if (direction === 'vertical') {
 | 
				
			||||||
 | 
					      while (widget.y > 0 && !checkCollision(widgets, widget, widget.x, widget.y - 1)) {
 | 
				
			||||||
 | 
					        widget.y -= 1;
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    } else {
 | 
				
			||||||
 | 
					      while (widget.x > 0 && !checkCollision(widgets, widget, widget.x - 1, widget.y)) {
 | 
				
			||||||
 | 
					        widget.x -= 1;
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export const applyLayout = (
 | 
				
			||||||
 | 
					  widgets: DashboardWidget[],
 | 
				
			||||||
 | 
					  layout: DashboardLayoutItem[],
 | 
				
			||||||
 | 
					): DashboardWidget[] => {
 | 
				
			||||||
 | 
					  return widgets.map(widget => {
 | 
				
			||||||
 | 
					    const layoutItem = layout.find(item => item.id === widget.id);
 | 
				
			||||||
 | 
					    return layoutItem ? { ...widget, ...layoutItem } : widget;
 | 
				
			||||||
 | 
					  });
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
							
								
								
									
										249
									
								
								ts_web/elements/dees-dashboardgrid/styles.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										249
									
								
								ts_web/elements/dees-dashboardgrid/styles.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,249 @@
 | 
				
			|||||||
 | 
					import { css, cssManager } from '@design.estate/dees-element';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export const dashboardGridStyles = [
 | 
				
			||||||
 | 
					  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-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')};
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      .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 {
 | 
				
			||||||
 | 
					        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 {
 | 
				
			||||||
 | 
					        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;
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					  `,
 | 
				
			||||||
 | 
					];
 | 
				
			||||||
							
								
								
									
										53
									
								
								ts_web/elements/dees-dashboardgrid/types.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										53
									
								
								ts_web/elements/dees-dashboardgrid/types.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,53 @@
 | 
				
			|||||||
 | 
					import type { TemplateResult } from '@design.estate/dees-element';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export type CellHeightUnit = 'px' | 'em' | 'rem' | 'auto';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export interface DashboardMarginObject {
 | 
				
			||||||
 | 
					  top?: number;
 | 
				
			||||||
 | 
					  right?: number;
 | 
				
			||||||
 | 
					  bottom?: number;
 | 
				
			||||||
 | 
					  left?: number;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export type DashboardMargin = number | DashboardMarginObject;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export interface DashboardResolvedMargins {
 | 
				
			||||||
 | 
					  horizontal: number;
 | 
				
			||||||
 | 
					  vertical: number;
 | 
				
			||||||
 | 
					  top: number;
 | 
				
			||||||
 | 
					  right: number;
 | 
				
			||||||
 | 
					  bottom: number;
 | 
				
			||||||
 | 
					  left: number;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export interface DashboardLayoutItem {
 | 
				
			||||||
 | 
					  id: string;
 | 
				
			||||||
 | 
					  x: number;
 | 
				
			||||||
 | 
					  y: number;
 | 
				
			||||||
 | 
					  w: number;
 | 
				
			||||||
 | 
					  h: number;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export interface DashboardWidget extends DashboardLayoutItem {
 | 
				
			||||||
 | 
					  minW?: number;
 | 
				
			||||||
 | 
					  minH?: number;
 | 
				
			||||||
 | 
					  maxW?: number;
 | 
				
			||||||
 | 
					  maxH?: number;
 | 
				
			||||||
 | 
					  content: TemplateResult | string;
 | 
				
			||||||
 | 
					  title?: string;
 | 
				
			||||||
 | 
					  icon?: string;
 | 
				
			||||||
 | 
					  noMove?: boolean;
 | 
				
			||||||
 | 
					  noResize?: boolean;
 | 
				
			||||||
 | 
					  locked?: boolean;
 | 
				
			||||||
 | 
					  autoPosition?: boolean;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export type LayoutDirection = 'vertical' | 'horizontal';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export interface GridCellMetrics {
 | 
				
			||||||
 | 
					  containerWidth: number;
 | 
				
			||||||
 | 
					  cellWidthPx: number;
 | 
				
			||||||
 | 
					  marginHorizontalPx: number;
 | 
				
			||||||
 | 
					  cellHeightPx: number;
 | 
				
			||||||
 | 
					  marginVerticalPx: number;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@@ -18,7 +18,7 @@ export * from './dees-chips.js';
 | 
				
			|||||||
export * from './dees-contextmenu.js';
 | 
					export * from './dees-contextmenu.js';
 | 
				
			||||||
export * from './dees-dataview-codebox.js';
 | 
					export * from './dees-dataview-codebox.js';
 | 
				
			||||||
export * from './dees-dataview-statusobject.js';
 | 
					export * from './dees-dataview-statusobject.js';
 | 
				
			||||||
export * from './dees-dashboardgrid.js';
 | 
					export * from './dees-dashboardgrid/index.js';
 | 
				
			||||||
export * from './dees-editor.js';
 | 
					export * from './dees-editor.js';
 | 
				
			||||||
export * from './dees-editor-markdown.js';
 | 
					export * from './dees-editor-markdown.js';
 | 
				
			||||||
export * from './dees-editor-markdownoutlet.js';
 | 
					export * from './dees-editor-markdownoutlet.js';
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,65 +0,0 @@
 | 
				
			|||||||
# WYSIWYG Block Cleanup Status
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
## Overview
 | 
					 | 
				
			||||||
This document tracks the cleanup of `dees-wysiwyg-block.ts` after migrating all block types to the new block handler architecture.
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
## Completed ✅
 | 
					 | 
				
			||||||
All cleanup tasks have been successfully completed on 2025-06-26.
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
## Cleanup Tasks
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
### 1. ✅ Remove Block-Specific Styles (lines 101-219)
 | 
					 | 
				
			||||||
- [x] Remove `.block.heading-1/2/3` styles → Now in `heading.block.ts`
 | 
					 | 
				
			||||||
- [x] Remove `.block.quote` styles → Now in `quote.block.ts`
 | 
					 | 
				
			||||||
- [x] Remove `.block.list` styles → Now in `list.block.ts`
 | 
					 | 
				
			||||||
- [x] Remove `.block.paragraph` styles → Now in `paragraph.block.ts`
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
### 2. ✅ Remove Code Block Specific Logic
 | 
					 | 
				
			||||||
- [x] Remove code block rendering in `renderBlockContent()` (lines 508-521)
 | 
					 | 
				
			||||||
- [x] Remove all `type === 'code'` conditional branches
 | 
					 | 
				
			||||||
- [x] Simplify element selection to not special-case code blocks
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
### 3. ✅ Remove List Block Specific Logic
 | 
					 | 
				
			||||||
- [x] Remove `focusListItem()` method (lines 814-821)
 | 
					 | 
				
			||||||
- [x] Remove list-specific handling in `getContent()` (lines 732-734)
 | 
					 | 
				
			||||||
- [x] Remove list-specific handling in `setContent()` (lines 764-765)
 | 
					 | 
				
			||||||
- [x] Remove list content rendering in `firstUpdated()` (line 479)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
### 4. ✅ Remove getPlaceholder() Method
 | 
					 | 
				
			||||||
- [x] Remove entire method (lines 538-553)
 | 
					 | 
				
			||||||
- [x] Update renderBlockContent() to not use placeholders
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
### 5. ✅ Clean Up Excessive Empty Lines
 | 
					 | 
				
			||||||
- [x] Remove consecutive blank lines throughout the file
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
### 6. ✅ Centralize nonEditableTypes
 | 
					 | 
				
			||||||
- [x] Create a single source of truth for non-editable block types
 | 
					 | 
				
			||||||
- [x] Remove duplicate arrays
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
### 7. ✅ Simplify Handler Delegation
 | 
					 | 
				
			||||||
- [x] Keep handler delegation pattern but ensure consistency
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
### 8. ✅ Remove Unused Properties (if confirmed unused)
 | 
					 | 
				
			||||||
- [x] Keep `contentInitialized` - still used for tracking
 | 
					 | 
				
			||||||
- [x] Keep `blockElement` - used for caching
 | 
					 | 
				
			||||||
- [x] Keep cursor tracking properties - used for selection
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
## Implementation Notes
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
### Block Types Now Fully Handled by Handlers:
 | 
					 | 
				
			||||||
1. **Text blocks**: paragraph, heading-1/2/3, quote, code, list
 | 
					 | 
				
			||||||
2. **Media blocks**: image, youtube, attachment  
 | 
					 | 
				
			||||||
3. **Content blocks**: divider, markdown, html
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
### Remaining Responsibilities of dees-wysiwyg-block.ts:
 | 
					 | 
				
			||||||
1. Shadow DOM container management
 | 
					 | 
				
			||||||
2. Handler delegation for all operations
 | 
					 | 
				
			||||||
3. Generic block wrapper styles
 | 
					 | 
				
			||||||
4. Selection/cursor tracking
 | 
					 | 
				
			||||||
5. Event listener setup (until fully delegated to handlers)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
## Future Improvements
 | 
					 | 
				
			||||||
- Consider moving all event handling to block handlers
 | 
					 | 
				
			||||||
- Simplify the handler delegation pattern
 | 
					 | 
				
			||||||
- Move generic block styles to a shared location
 | 
					 | 
				
			||||||
- Consider removing the need for special-casing any block types
 | 
					 | 
				
			||||||
@@ -1,87 +0,0 @@
 | 
				
			|||||||
# WYSIWYG Block Migration Status
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
## Overview
 | 
					 | 
				
			||||||
This document tracks the progress of migrating all WYSIWYG blocks to the new block handler architecture.
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
## Migration Progress
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
### ✅ Phase 1: Architecture Foundation
 | 
					 | 
				
			||||||
- Created block handler base classes and interfaces
 | 
					 | 
				
			||||||
- Created block registry system  
 | 
					 | 
				
			||||||
- Created common block styles and utilities
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
### ✅ Phase 2: Divider Block
 | 
					 | 
				
			||||||
- Simple non-editable block as proof of concept
 | 
					 | 
				
			||||||
- See `phase2-summary.md` for details
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
### ✅ Phase 3: Paragraph Block
 | 
					 | 
				
			||||||
- First text block with full editing capabilities
 | 
					 | 
				
			||||||
- Established patterns for text selection, cursor tracking, and content splitting
 | 
					 | 
				
			||||||
- See commit history for implementation details
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
### ✅ Phase 4: Heading Blocks
 | 
					 | 
				
			||||||
- All three heading levels (h1, h2, h3) using unified handler
 | 
					 | 
				
			||||||
- See `phase4-summary.md` for details
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
### ✅ Phase 5: Other Text Blocks
 | 
					 | 
				
			||||||
- [x] Quote block - Completed with custom styling
 | 
					 | 
				
			||||||
- [x] Code block - Completed with syntax highlighting, line numbers, and copy button
 | 
					 | 
				
			||||||
- [x] List block - Completed with bullet and numbered list support
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
### 🔄 Phase 6: Media Blocks (In Progress)
 | 
					 | 
				
			||||||
- [x] Image block - Completed with click upload, drag-drop, and base64 encoding
 | 
					 | 
				
			||||||
- [x] YouTube block - Completed with URL parsing and video embedding
 | 
					 | 
				
			||||||
- [ ] Attachment block
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
### 📋 Phase 7: Content Blocks (Planned)
 | 
					 | 
				
			||||||
- [ ] Markdown block
 | 
					 | 
				
			||||||
- [ ] HTML block
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
## Block Handler Status
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
| Block Type | Handler Created | Registered | Tested | Notes |
 | 
					 | 
				
			||||||
|------------|----------------|------------|---------|-------|
 | 
					 | 
				
			||||||
| divider | ✅ | ✅ | ✅ | Complete |
 | 
					 | 
				
			||||||
| paragraph | ✅ | ✅ | ✅ | Complete |
 | 
					 | 
				
			||||||
| heading-1 | ✅ | ✅ | ✅ | Using HeadingBlockHandler |
 | 
					 | 
				
			||||||
| heading-2 | ✅ | ✅ | ✅ | Using HeadingBlockHandler |
 | 
					 | 
				
			||||||
| heading-3 | ✅ | ✅ | ✅ | Using HeadingBlockHandler |
 | 
					 | 
				
			||||||
| quote | ✅ | ✅ | ✅ | Complete with custom styling |
 | 
					 | 
				
			||||||
| code | ✅ | ✅ | ✅ | Complete with highlighting, line numbers, copy |
 | 
					 | 
				
			||||||
| list | ✅ | ✅ | ✅ | Complete with bullet/numbered support |
 | 
					 | 
				
			||||||
| image | ✅ | ✅ | ✅ | Complete with upload, drag-drop support |
 | 
					 | 
				
			||||||
| youtube | ✅ | ✅ | ✅ | Complete with URL parsing, video embedding |
 | 
					 | 
				
			||||||
| attachment | ❌ | ❌ | ❌ | Phase 6 |
 | 
					 | 
				
			||||||
| markdown | ❌ | ❌ | ❌ | Phase 7 |
 | 
					 | 
				
			||||||
| html | ❌ | ❌ | ❌ | Phase 7 |
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
## Files Modified During Migration
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
### Core Architecture Files
 | 
					 | 
				
			||||||
- `blocks/block.base.ts` - Base handler interface and class
 | 
					 | 
				
			||||||
- `blocks/block.registry.ts` - Registry for handlers
 | 
					 | 
				
			||||||
- `blocks/block.styles.ts` - Common styles
 | 
					 | 
				
			||||||
- `blocks/index.ts` - Main exports
 | 
					 | 
				
			||||||
- `wysiwyg.blockregistration.ts` - Registration of all handlers
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
### Handler Files Created
 | 
					 | 
				
			||||||
- `blocks/content/divider.block.ts`
 | 
					 | 
				
			||||||
- `blocks/text/paragraph.block.ts`
 | 
					 | 
				
			||||||
- `blocks/text/heading.block.ts`
 | 
					 | 
				
			||||||
- `blocks/text/quote.block.ts`
 | 
					 | 
				
			||||||
- `blocks/text/code.block.ts`
 | 
					 | 
				
			||||||
- `blocks/text/list.block.ts`
 | 
					 | 
				
			||||||
- `blocks/media/image.block.ts`
 | 
					 | 
				
			||||||
- `blocks/media/youtube.block.ts`
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
### Main Component Updates
 | 
					 | 
				
			||||||
- `dees-wysiwyg-block.ts` - Updated to use registry pattern
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
## Next Steps
 | 
					 | 
				
			||||||
1. Begin Phase 6: Media blocks migration
 | 
					 | 
				
			||||||
   - Start with image block (most common media type)
 | 
					 | 
				
			||||||
   - Implement YouTube block for video embedding
 | 
					 | 
				
			||||||
   - Create attachment block for file uploads
 | 
					 | 
				
			||||||
2. Follow established patterns from existing handlers
 | 
					 | 
				
			||||||
3. Test thoroughly after each migration
 | 
					 | 
				
			||||||
4. Update documentation as blocks are completed
 | 
					 | 
				
			||||||
@@ -1,7 +0,0 @@
 | 
				
			|||||||
* We don't use lit html logic, no event binding, no nothing, but only use static`` here to handle dom operations ourselves
 | 
					 | 
				
			||||||
* We try to have separated concerns in different classes
 | 
					 | 
				
			||||||
* We try to have clean concise and managable code
 | 
					 | 
				
			||||||
* lets log whats happening, so if something goes wrong, we understand whats happening.
 | 
					 | 
				
			||||||
* Read https://developer.mozilla.org/en-US/docs/Web/API/Selection/getComposedRanges
 | 
					 | 
				
			||||||
* Read https://developer.mozilla.org/en-US/docs/Web/API/Selection/getComposedRanges
 | 
					 | 
				
			||||||
* Make sure to hand over correct shodowroots.
 | 
					 | 
				
			||||||
		Reference in New Issue
	
	Block a user