diff --git a/ts_web/elements/dees-dashboardgrid.ts b/ts_web/elements/dees-dashboardgrid.ts
deleted file mode 100644
index d833a04..0000000
--- a/ts_web/elements/dees-dashboardgrid.ts
+++ /dev/null
@@ -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`
-
-
-
No widgets configured
-
Add widgets to populate the dashboard
-
- `;
- }
-
- const margins = this.getMargins();
- const maxY = Math.max(...this.widgets.map(w => w.y + w.h), 4);
- const cellHeightValue = this.getCellHeight();
- const gridHeight = maxY * cellHeightValue + (maxY + 1) * margins.vertical;
-
- return html`
-
- ${this.showGridLines ? this.renderGridLines(gridHeight) : ''}
- ${this.widgets.map(widget => this.renderWidget(widget))}
- ${this.placeholderPosition && this.draggedWidget ? this.renderPlaceholder() : ''}
-
- `;
- }
-
- private renderGridLines(gridHeight: number): TemplateResult {
- const margins = this.getMargins();
- const cellHeightValue = this.getCellHeight();
-
- // Convert margin to percentage for consistent calculation
- const containerWidth = this.getBoundingClientRect().width;
- const marginHorizontalPercent = (margins.horizontal / containerWidth) * 100;
-
- const cellWidth = (100 - marginHorizontalPercent * (this.columns + 1)) / this.columns;
-
- const verticalLines = [];
- const horizontalLines = [];
-
- // Vertical lines
- for (let i = 0; i <= this.columns; i++) {
- const left = i * cellWidth + i * marginHorizontalPercent;
- verticalLines.push(html`
-
- `);
- }
-
- // Horizontal lines
- const numHorizontalLines = Math.ceil(gridHeight / (cellHeightValue + margins.vertical));
- for (let i = 0; i <= numHorizontalLines; i++) {
- const top = i * cellHeightValue + i * margins.vertical;
- horizontalLines.push(html`
-
- `);
- }
-
- return html`
-
- ${verticalLines}
- ${horizontalLines}
-
- `;
- }
-
- private renderWidget(widget: IDashboardWidget): TemplateResult {
- const isDragging = this.draggedWidget?.id === widget.id;
- const isResizing = this.resizingWidget?.id === widget.id;
- const isLocked = widget.locked || !this.editable;
-
- const margins = this.getMargins();
- const cellHeightValue = this.getCellHeight();
-
- // Convert margin to percentage of container width for consistent calculation
- const containerWidth = this.getBoundingClientRect().width;
- const marginHorizontalPercent = (margins.horizontal / containerWidth) * 100;
-
- const cellWidth = (100 - marginHorizontalPercent * (this.columns + 1)) / this.columns;
-
- const left = widget.x * cellWidth + (widget.x + 1) * marginHorizontalPercent;
- const top = widget.y * cellHeightValue + (widget.y + 1) * margins.vertical;
- const width = widget.w * cellWidth + (widget.w - 1) * marginHorizontalPercent;
- const height = widget.h * cellHeightValue + (widget.h - 1) * margins.vertical;
-
- // Apply transform when dragging for smooth movement
- let transform = '';
- if (isDragging && this.draggedElement) {
- const containerRect = this.getBoundingClientRect();
- const translateX = this.dragMouseX - containerRect.left - this.dragOffsetX - (left / 100 * containerRect.width);
- const translateY = this.dragMouseY - containerRect.top - this.dragOffsetY - top;
- transform = `transform: translate(${translateX}px, ${translateY}px);`;
- }
-
- return html`
-
- `;
- }
-
- private renderPlaceholder(): TemplateResult {
- if (!this.placeholderPosition || !this.draggedWidget) return html``;
-
- const margins = this.getMargins();
- const cellHeightValue = this.getCellHeight();
-
- // Convert margin to percentage of container width for consistent calculation
- const containerWidth = this.getBoundingClientRect().width;
- const marginHorizontalPercent = (margins.horizontal / containerWidth) * 100;
-
- const cellWidth = (100 - marginHorizontalPercent * (this.columns + 1)) / this.columns;
-
- const left = this.placeholderPosition.x * cellWidth + (this.placeholderPosition.x + 1) * marginHorizontalPercent;
- const top = this.placeholderPosition.y * cellHeightValue + (this.placeholderPosition.y + 1) * margins.vertical;
- const width = this.draggedWidget.w * cellWidth + (this.draggedWidget.w - 1) * marginHorizontalPercent;
- const height = this.draggedWidget.h * cellHeightValue + (this.draggedWidget.h - 1) * margins.vertical;
-
- return html`
-
- `;
- }
-
- private startDrag(e: MouseEvent, widget: IDashboardWidget) {
- e.preventDefault();
- this.draggedWidget = widget;
- this.draggedElement = (e.currentTarget as HTMLElement).closest('.grid-widget') as HTMLElement;
-
- const rect = this.draggedElement.getBoundingClientRect();
-
- this.dragOffsetX = e.clientX - rect.left;
- this.dragOffsetY = e.clientY - rect.top;
-
- // Initialize mouse position
- this.dragMouseX = e.clientX;
- this.dragMouseY = e.clientY;
-
- // Initialize placeholder at current widget position
- this.placeholderPosition = { x: widget.x, y: widget.y };
-
- document.addEventListener('mousemove', this.handleDrag);
- document.addEventListener('mouseup', this.endDrag);
-
- this.requestUpdate();
- }
-
- private handleDrag = (e: MouseEvent) => {
- if (!this.draggedWidget || !this.draggedElement) return;
-
- // Update mouse position for smooth dragging
- this.dragMouseX = e.clientX;
- this.dragMouseY = e.clientY;
-
- const containerRect = this.getBoundingClientRect();
- const margins = this.getMargins();
- const cellHeightValue = this.getCellHeight();
-
- // 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) {
- this.widgets = this.widgets.map(w =>
- w.id === widgetId ? { ...w, ...updates } : w
- );
- }
-
- public getLayout(): Array<{ id: string; x: number; y: number; w: number; h: number }> {
- return this.widgets.map(({ id, x, y, w, h }) => ({ id, x, y, w, h }));
- }
-
- public setLayout(layout: Array<{ id: string; x: number; y: number; w: number; h: number }>) {
- this.widgets = this.widgets.map(widget => {
- const layoutItem = layout.find(l => l.id === widget.id);
- return layoutItem ? { ...widget, ...layoutItem } : widget;
- });
- }
-
- public lockGrid() {
- this.editable = false;
- }
-
- public unlockGrid() {
- this.editable = true;
- }
-
- private getMargins(): { horizontal: number; vertical: number; top: number; right: number; bottom: number; left: number } {
- if (typeof this.margin === 'number') {
- return {
- horizontal: this.margin,
- vertical: this.margin,
- top: this.margin,
- right: this.margin,
- bottom: this.margin,
- left: this.margin,
- };
- }
-
- const margins = {
- top: this.margin.top ?? 10,
- right: this.margin.right ?? 10,
- bottom: this.margin.bottom ?? 10,
- left: this.margin.left ?? 10,
- };
-
- return {
- ...margins,
- horizontal: (margins.left + margins.right) / 2,
- vertical: (margins.top + margins.bottom) / 2,
- };
- }
-
- private getCellHeight(): number {
- if (this.cellHeightUnit === 'auto') {
- // Calculate square cells based on container width
- const containerWidth = this.getBoundingClientRect().width;
- const margins = this.getMargins();
- const cellWidth = (containerWidth - margins.horizontal * (this.columns + 1)) / this.columns;
- return cellWidth;
- }
-
- return this.cellHeight;
- }
-
- private checkCollision(widget: IDashboardWidget, newX: number, newY: number): boolean {
- const widgets = this.widgets.filter(w => w.id !== widget.id);
-
- for (const other of widgets) {
- if (newX < other.x + other.w &&
- newX + widget.w > other.x &&
- newY < other.y + other.h &&
- newY + widget.h > other.y) {
- return true;
- }
- }
-
- return false;
- }
-
- public addWidget(widget: IDashboardWidget, autoPosition = false) {
- if (autoPosition || widget.autoPosition) {
- // Find first available position
- const position = this.findAvailablePosition(widget.w, widget.h);
- widget.x = position.x;
- widget.y = position.y;
- }
-
- this.widgets = [...this.widgets, widget];
- }
-
- private findAvailablePosition(width: number, height: number): { x: number; y: number } {
- // Try to find space starting from top-left
- for (let y = 0; y < 100; y++) { // Reasonable limit
- for (let x = 0; x <= this.columns - width; x++) {
- const testWidget = { id: 'test', x, y, w: width, h: height, content: '' } as IDashboardWidget;
- if (!this.checkCollision(testWidget, x, y)) {
- return { x, y };
- }
- }
- }
-
- // If no space found, place at bottom
- const maxY = Math.max(...this.widgets.map(w => w.y + w.h), 0);
- return { x: 0, y: maxY };
- }
-
- public compact(direction: 'vertical' | 'horizontal' = 'vertical') {
- const sortedWidgets = [...this.widgets].sort((a, b) => {
- if (direction === 'vertical') {
- if (a.y !== b.y) return a.y - b.y;
- return a.x - b.x;
- } else {
- if (a.x !== b.x) return a.x - b.x;
- return a.y - b.y;
- }
- });
-
- for (const widget of sortedWidgets) {
- if (widget.locked || widget.noMove) continue;
-
- if (direction === 'vertical') {
- // Move up as far as possible
- while (widget.y > 0 && !this.checkCollision(widget, widget.x, widget.y - 1)) {
- widget.y--;
- }
- } else {
- // Move left as far as possible
- while (widget.x > 0 && !this.checkCollision(widget, widget.x - 1, widget.y)) {
- widget.x--;
- }
- }
- }
-
- this.requestUpdate();
- }
-}
\ No newline at end of file
diff --git a/ts_web/elements/dees-dashboardgrid/README.md b/ts_web/elements/dees-dashboardgrid/README.md
new file mode 100644
index 0000000..aaab6a4
--- /dev/null
+++ b/ts_web/elements/dees-dashboardgrid/README.md
@@ -0,0 +1,47 @@
+# 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.
diff --git a/ts_web/elements/dees-dashboardgrid/contextmenu.ts b/ts_web/elements/dees-dashboardgrid/contextmenu.ts
new file mode 100644
index 0000000..a96ec54
--- /dev/null
+++ b/ts_web/elements/dees-dashboardgrid/contextmenu.ts
@@ -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);
+};
diff --git a/ts_web/elements/dees-dashboardgrid.demo.ts b/ts_web/elements/dees-dashboardgrid/dees-dashboardgrid.demo.ts
similarity index 50%
rename from ts_web/elements/dees-dashboardgrid.demo.ts
rename to ts_web/elements/dees-dashboardgrid/dees-dashboardgrid.demo.ts
index 945ee59..a9f413d 100644
--- a/ts_web/elements/dees-dashboardgrid.demo.ts
+++ b/ts_web/elements/dees-dashboardgrid/dees-dashboardgrid.demo.ts
@@ -6,9 +6,8 @@ export const demoFunc = () => {
return html`
{
const grid = elementArg.querySelector('#dashboardGrid') as DeesDashboardgrid;
-
- // Set initial widgets
- grid.widgets = [
+
+ const seedWidgets = [
{
id: 'metrics1',
x: 0,
@@ -22,7 +21,7 @@ export const demoFunc = () => {
$124,563
↑ 12.5% from last month
- `
+ `,
},
{
id: 'metrics2',
@@ -37,7 +36,7 @@ export const demoFunc = () => {
8,234
↑ 5.2% from last week
- `
+ `,
},
{
id: 'chart1',
@@ -54,71 +53,126 @@ export const demoFunc = () => {
Chart visualization area
- `
- }
+ `,
+ },
];
-
- // Configure grid
+
+ grid.widgets = seedWidgets.map(widget => ({ ...widget }));
grid.cellHeight = 80;
grid.margin = { top: 10, right: 10, bottom: 10, left: 10 };
grid.enableAnimation = true;
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;
-
- // Control buttons
+
const buttons = elementArg.querySelectorAll('dees-button');
buttons.forEach(button => {
const text = button.textContent?.trim();
-
- if (text === 'Toggle Animation') {
- button.addEventListener('click', () => {
- grid.enableAnimation = !grid.enableAnimation;
- });
- } else if (text === 'Toggle Grid Lines') {
- button.addEventListener('click', () => {
- grid.showGridLines = !grid.showGridLines;
- });
- } else if (text === 'Add Widget') {
- button.addEventListener('click', () => {
- const newWidget = {
- id: `widget${widgetCounter++}`,
- x: 0,
- y: 0,
- w: 3,
- h: 2,
- autoPosition: true,
- title: `Widget ${widgetCounter - 1}`,
- icon: 'lucide:package',
- content: html`
-
-
New widget content
-
${Math.floor(Math.random() * 1000)}
-
- `
- };
- grid.addWidget(newWidget, true);
- });
- } else if (text === 'Compact Grid') {
- button.addEventListener('click', () => {
- grid.compact();
- });
- } else if (text === 'Toggle Edit Mode') {
- button.addEventListener('click', () => {
- grid.editable = !grid.editable;
- button.textContent = grid.editable ? 'Lock Grid' : 'Unlock Grid';
- });
+
+ switch (text) {
+ case 'Toggle Animation':
+ button.addEventListener('click', () => {
+ grid.enableAnimation = !grid.enableAnimation;
+ });
+ break;
+ case 'Toggle Grid Lines':
+ button.addEventListener('click', () => {
+ grid.showGridLines = !grid.showGridLines;
+ });
+ break;
+ case 'Add Widget':
+ button.addEventListener('click', () => {
+ const newWidget = {
+ id: `widget${widgetCounter++}`,
+ x: 0,
+ y: 0,
+ w: 3,
+ h: 2,
+ autoPosition: true,
+ title: `Widget ${widgetCounter - 1}`,
+ icon: 'lucide:package',
+ content: html`
+
+
New widget content
+
${Math.floor(
+ Math.random() * 1000,
+ )}
+
+ `,
+ };
+ grid.addWidget(newWidget, true);
+ });
+ break;
+ case 'Compact Grid':
+ button.addEventListener('click', () => {
+ grid.compact();
+ });
+ break;
+ case 'Toggle Edit Mode':
+ button.addEventListener('click', () => {
+ grid.editable = !grid.editable;
+ 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) => {
- 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);
+ 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();
+ });
+
+ updateStatus();
}}>
@@ -163,29 +225,31 @@ export const demoFunc = () => {
Toggle Animation
-
+
Toggle Grid Lines
-
+
Add Widget
Compact Grid
+ Reset Layout
-
+
Toggle Edit Mode
-
+
-
+
- Drag widgets to reposition • Resize from edges and corners • Add widgets with auto-positioning
+
Drag to reposition, resize from handles, or right-click a header to delete a tile.
+
`;
-};
\ No newline at end of file
+};
diff --git a/ts_web/elements/dees-dashboardgrid/dees-dashboardgrid.ts b/ts_web/elements/dees-dashboardgrid/dees-dashboardgrid.ts
new file mode 100644
index 0000000..02617c5
--- /dev/null
+++ b/ts_web/elements/dees-dashboardgrid/dees-dashboardgrid.ts
@@ -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;
+
+ @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): 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`
+
+
+
No widgets configured
+
Add widgets to populate the dashboard
+
+ `;
+ }
+
+ const metrics = this.ensureMetrics();
+ const margins = this.resolvedMargins ?? resolveMargins(this.margin);
+ const cellHeight = metrics.cellHeightPx;
+ const gridHeight = calculateGridHeight(this.widgets, margins, cellHeight);
+
+ return html`
+
+ ${this.showGridLines ? this.renderGridLines(metrics, gridHeight) : null}
+ ${this.widgets.map(widget => this.renderWidget(widget, metrics, margins))}
+ ${this.placeholderPosition ? this.renderPlaceholder(metrics, margins) : null}
+
+ `;
+ }
+
+ private renderGridLines(metrics: GridCellMetrics, gridHeight: number): TemplateResult {
+ const vertical: TemplateResult[] = [];
+ const horizontal: TemplateResult[] = [];
+ const cellPlusMarginX = metrics.cellWidthPx + metrics.marginHorizontalPx;
+ const cellPlusMarginY = metrics.cellHeightPx + metrics.marginVerticalPx;
+
+ for (let i = 0; i <= this.columns; i++) {
+ const leftPx = i * cellPlusMarginX + metrics.marginHorizontalPx;
+ const leftPercent = this.pxToPercent(leftPx, metrics.containerWidth);
+ vertical.push(html``);
+ }
+
+ const rows = Math.ceil(gridHeight / cellPlusMarginY);
+ for (let row = 0; row <= rows; row++) {
+ const top = row * cellPlusMarginY;
+ horizontal.push(html``);
+ }
+
+ return html`
+
+ ${vertical}
+ ${horizontal}
+
+ `;
+ }
+
+ private renderWidget(
+ widget: DashboardWidget,
+ metrics: GridCellMetrics,
+ margins: DashboardResolvedMargins,
+ ): 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`
+
+ `;
+ }
+
+ private renderPlaceholder(
+ metrics: GridCellMetrics,
+ margins: DashboardResolvedMargins,
+ ): TemplateResult {
+ if (!this.placeholderPosition) {
+ return html``;
+ }
+
+ const rect = this.computeWidgetRect(this.placeholderPosition, metrics, margins);
+ const sideProperty = this.rtl ? 'right' : 'left';
+ const sideValue = this.pxToPercent(rect.left, metrics.containerWidth);
+ const widthPercent = this.pxToPercent(rect.width, metrics.containerWidth);
+
+ return html`
+
+ `;
+ }
+
+ private startDrag(event: PointerEvent, widget: DashboardWidget): void {
+ if (!this.editable || widget.noMove || widget.locked) {
+ return;
+ }
+
+ event.preventDefault();
+ event.stopPropagation();
+
+ const widgetElement = (event.currentTarget as HTMLElement).closest('.grid-widget') as HTMLElement | null;
+ if (!widgetElement) {
+ return;
+ }
+
+ const widgetRect = widgetElement.getBoundingClientRect();
+ this.containerBounds = this.getBoundingClientRect();
+ this.ensureMetrics();
+
+ this.dragState = {
+ widgetId: widget.id,
+ pointerId: event.pointerId,
+ offsetX: event.clientX - widgetRect.left,
+ offsetY: event.clientY - widgetRect.top,
+ start: { id: widget.id, x: widget.x, y: widget.y, w: widget.w, h: widget.h },
+ 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 = {
+ 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): void {
+ this.widgets = this.widgets.map(widget => (widget.id === widgetId ? { ...widget, ...updates } : widget));
+ }
+
+ public getLayout(): DashboardLayoutItem[] {
+ return this.widgets.map(({ id, x, y, w, h }) => ({ id, x, y, w, h }));
+ }
+
+ public setLayout(layout: DashboardLayoutItem[]): void {
+ this.widgets = applyLayout(this.widgets, layout);
+ }
+
+ public lockGrid(): void {
+ this.editable = false;
+ }
+
+ public unlockGrid(): void {
+ this.editable = true;
+ }
+
+ public addWidget(widget: DashboardWidget, autoPosition = false): void {
+ const nextWidget = { ...widget };
+ if (autoPosition || nextWidget.autoPosition) {
+ const position = findAvailablePosition(this.widgets, nextWidget.w, nextWidget.h, this.columns);
+ nextWidget.x = position.x;
+ nextWidget.y = position.y;
+ }
+
+ this.widgets = [...this.widgets, nextWidget];
+ }
+
+ public compact(direction: LayoutDirection = 'vertical'): void {
+ const nextWidgets = this.widgets.map(widget => ({ ...widget }));
+ compactLayout(nextWidgets, direction);
+ this.widgets = nextWidgets;
+ }
+
+ public applyBreakpointLayout(breakpoint: string): void {
+ this.activeBreakpoint = breakpoint;
+ const layout = this.layouts?.[breakpoint];
+ if (layout) {
+ this.setLayout(layout);
+ }
+ }
+
+ public notifyLayoutChange(): void {
+ this.dispatchEvent(
+ new CustomEvent('layout-change', {
+ detail: { layout: this.getLayout() },
+ bubbles: true,
+ composed: true,
+ }),
+ );
+ }
+
+ private ensureMetrics(): GridCellMetrics {
+ if (!this.metrics) {
+ this.computeMetrics();
+ }
+ return this.metrics!;
+ }
+
+ private computeMetrics(): void {
+ if (!this.isConnected) return;
+ const bounds = this.getBoundingClientRect();
+ this.containerBounds = bounds;
+ const margins = resolveMargins(this.margin);
+ this.resolvedMargins = margins;
+ this.metrics = calculateCellMetrics(bounds.width, this.columns, margins, this.cellHeight, this.cellHeightUnit);
+ }
+
+ private observeResize(): void {
+ if (this.resizeObserver) return;
+ this.resizeObserver = new ResizeObserver(() => {
+ this.computeMetrics();
+ });
+ this.resizeObserver.observe(this);
+ }
+
+ private disconnectResizeObserver(): void {
+ this.resizeObserver?.disconnect();
+ this.resizeObserver = undefined;
+ }
+
+ private releasePointerEvents(): void {
+ document.removeEventListener('pointermove', this.handleDragMove);
+ document.removeEventListener('pointerup', this.handleDragEnd);
+ document.removeEventListener('pointermove', this.handleResizeMove);
+ document.removeEventListener('pointerup', this.handleResizeEnd);
+ }
+
+ private pxToPercent(value: number, container: number): number {
+ if (!container) return 0;
+ return Number(((value / container) * 100).toFixed(4));
+ }
+
+ private computeWidgetRect(
+ widget: Pick,
+ metrics: GridCellMetrics,
+ margins: DashboardResolvedMargins,
+ ) {
+ const cellWidth = metrics.cellWidthPx;
+ const cellHeight = metrics.cellHeightPx;
+ const left = widget.x * (cellWidth + margins.horizontal) + margins.horizontal;
+ const top = widget.y * (cellHeight + margins.vertical) + margins.vertical;
+ const width = widget.w * cellWidth + Math.max(0, widget.w - 1) * margins.horizontal;
+ const height = widget.h * cellHeight + Math.max(0, widget.h - 1) * margins.vertical;
+
+ return { left, top, width, height };
+ }
+}
diff --git a/ts_web/elements/dees-dashboardgrid/index.ts b/ts_web/elements/dees-dashboardgrid/index.ts
new file mode 100644
index 0000000..a131b52
--- /dev/null
+++ b/ts_web/elements/dees-dashboardgrid/index.ts
@@ -0,0 +1,2 @@
+export * from './dees-dashboardgrid.js';
+export * from './types.js';
diff --git a/ts_web/elements/dees-dashboardgrid/interaction.ts b/ts_web/elements/dees-dashboardgrid/interaction.ts
new file mode 100644
index 0000000..6be6d4c
--- /dev/null
+++ b/ts_web/elements/dees-dashboardgrid/interaction.ts
@@ -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,
+ };
+};
diff --git a/ts_web/elements/dees-dashboardgrid/layout.ts b/ts_web/elements/dees-dashboardgrid/layout.ts
new file mode 100644
index 0000000..018cee4
--- /dev/null
+++ b/ts_web/elements/dees-dashboardgrid/layout.ts
@@ -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([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;
+ });
+};
diff --git a/ts_web/elements/dees-dashboardgrid/styles.ts b/ts_web/elements/dees-dashboardgrid/styles.ts
new file mode 100644
index 0000000..41e4d8e
--- /dev/null
+++ b/ts_web/elements/dees-dashboardgrid/styles.ts
@@ -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;
+ }
+ `,
+];
diff --git a/ts_web/elements/dees-dashboardgrid/types.ts b/ts_web/elements/dees-dashboardgrid/types.ts
new file mode 100644
index 0000000..3a80395
--- /dev/null
+++ b/ts_web/elements/dees-dashboardgrid/types.ts
@@ -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;
+}
diff --git a/ts_web/elements/index.ts b/ts_web/elements/index.ts
index 853e97a..301ae6c 100644
--- a/ts_web/elements/index.ts
+++ b/ts_web/elements/index.ts
@@ -18,7 +18,7 @@ export * from './dees-chips.js';
export * from './dees-contextmenu.js';
export * from './dees-dataview-codebox.js';
export * from './dees-dataview-statusobject.js';
-export * from './dees-dashboardgrid.js';
+export * from './dees-dashboardgrid/index.js';
export * from './dees-editor.js';
export * from './dees-editor-markdown.js';
export * from './dees-editor-markdownoutlet.js';
diff --git a/ts_web/elements/wysiwyg/CLEANUP-STATUS.md b/ts_web/elements/wysiwyg/CLEANUP-STATUS.md
deleted file mode 100644
index 9904dee..0000000
--- a/ts_web/elements/wysiwyg/CLEANUP-STATUS.md
+++ /dev/null
@@ -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
\ No newline at end of file
diff --git a/ts_web/elements/wysiwyg/MIGRATION-STATUS.md b/ts_web/elements/wysiwyg/MIGRATION-STATUS.md
deleted file mode 100644
index 6b9e2d9..0000000
--- a/ts_web/elements/wysiwyg/MIGRATION-STATUS.md
+++ /dev/null
@@ -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
\ No newline at end of file
diff --git a/ts_web/elements/wysiwyg/instructions.md b/ts_web/elements/wysiwyg/instructions.md
deleted file mode 100644
index 6c7a07e..0000000
--- a/ts_web/elements/wysiwyg/instructions.md
+++ /dev/null
@@ -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.