Files
dees-catalog/ts_web/elements/dees-dashboardgrid/dees-dashboardgrid.ts

797 lines
25 KiB
TypeScript
Raw Normal View History

import {
DeesElement,
customElement,
property,
state,
html,
type TemplateResult,
} from '@design.estate/dees-element';
import '../dees-icon.js';
import '../dees-contextmenu.js';
import { demoFunc } from './dees-dashboardgrid.demo.js';
import { dashboardGridStyles } from './styles.js';
import {
resolveMargins,
calculateCellMetrics,
calculateGridHeight,
findAvailablePosition,
compactLayout,
applyLayout,
resolveWidgetPlacement,
type PlacementResult,
} from './layout.js';
import {
computeGridCoordinates,
computeResizeDimensions,
type PointerPosition,
} from './interaction.js';
import { openWidgetContextMenu } from './contextmenu.js';
import type {
DashboardWidget,
DashboardMargin,
DashboardResolvedMargins,
GridCellMetrics,
DashboardLayoutItem,
LayoutDirection,
CellHeightUnit,
} from './types.js';
declare global {
interface HTMLElementTagNameMap {
'dees-dashboardgrid': DeesDashboardgrid;
}
}
type DragState = {
widgetId: string;
pointerId: number;
offsetX: number;
offsetY: number;
start: DashboardLayoutItem;
previousPosition: DashboardLayoutItem;
currentPointer: PointerPosition;
lastPlacement: PlacementResult | null;
};
type ResizeState = {
widgetId: string;
pointerId: number;
handler: 'e' | 's' | 'se';
startPointer: PointerPosition;
start: DashboardLayoutItem;
startWidth: number;
startHeight: number;
lastPlacement: PlacementResult | null;
};
@customElement('dees-dashboardgrid')
export class DeesDashboardgrid extends DeesElement {
public static demo = demoFunc;
public static styles = dashboardGridStyles;
@property({ type: Array })
public widgets: DashboardWidget[] = [];
@property({ type: Number })
public cellHeight: number = 80;
@property({ type: Object })
public margin: DashboardMargin = 10;
@property({ type: Number })
public columns: number = 12;
@property({ type: Boolean })
public editable: boolean = true;
@property({ type: Boolean, reflect: true })
public enableAnimation: boolean = true;
@property({ type: String })
public cellHeightUnit: CellHeightUnit = 'px';
@property({ type: Boolean })
public rtl: boolean = false;
@property({ type: Boolean })
public showGridLines: boolean = false;
@property({ attribute: false })
public layouts?: Record<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;
@state()
private previewWidgets: DashboardWidget[] | null = null;
private containerBounds: DOMRect | null = null;
private dragState: DragState | null = null;
private resizeState: ResizeState | null = null;
private resizeObserver?: ResizeObserver;
private interactionActive = false;
public override async connectedCallback(): Promise<void> {
await super.connectedCallback();
this.computeMetrics();
this.observeResize();
}
public override async disconnectedCallback(): Promise<void> {
await 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 {
const baseWidgets = this.widgets;
if (baseWidgets.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 layoutForHeight = this.previewWidgets ?? this.widgets;
const gridHeight = calculateGridHeight(layoutForHeight, margins, cellHeight);
const previewMap = this.previewWidgets ? new Map(this.previewWidgets.map(widget => [widget.id, widget])) : null;
return html`
<div class="grid-container" style="height: ${gridHeight}px;">
${this.showGridLines ? this.renderGridLines(metrics, gridHeight) : null}
${baseWidgets.map(widget => this.renderWidget(widget, metrics, margins, previewMap))}
${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,
previewMap: Map<string, DashboardWidget> | null,
): TemplateResult {
const isDragging = this.dragState?.widgetId === widget.id;
const isResizing = this.resizeState?.widgetId === widget.id;
const isLocked = widget.locked || !this.editable;
const previewWidget = previewMap?.get(widget.id) ?? null;
const layoutForRender = isDragging ? widget : previewWidget ?? widget;
const rect = this.computeWidgetRect(layoutForRender, metrics, margins);
const sideProperty = this.rtl ? 'right' : 'left';
const sideValue = this.pxToPercent(rect.left, metrics.containerWidth);
const widthPercent = this.pxToPercent(rect.width, metrics.containerWidth);
let transform = '';
if (isDragging && this.dragState?.currentPointer) {
const pointer = this.dragState.currentPointer;
const bounds = this.containerBounds ?? this.getBoundingClientRect();
const translateX = pointer.clientX - bounds.left - this.dragState.offsetX - rect.left;
const translateY = pointer.clientY - bounds.top - this.dragState.offsetY - rect.top;
transform = `transform: translate(${translateX}px, ${translateY}px);`;
}
return html`
<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 },
previousPosition: { id: widget.id, x: widget.x, y: widget.y, w: widget.w, h: widget.h },
currentPointer: { clientX: event.clientX, clientY: event.clientY },
lastPlacement: null,
};
this.interactionActive = true;
(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId);
document.addEventListener('pointermove', this.handleDragMove);
document.addEventListener('pointerup', this.handleDragEnd);
this.placeholderPosition = { id: widget.id, x: widget.x, y: widget.y, w: widget.w, h: widget.h };
}
private handleDragMove = (event: PointerEvent): void => {
if (!this.dragState) return;
const metrics = this.ensureMetrics();
const activeWidgets = this.widgets;
const widget = activeWidgets.find(item => item.id === this.dragState!.widgetId);
if (!widget) return;
event.preventDefault();
const previousPosition = this.dragState.previousPosition;
const coords = computeGridCoordinates({
pointer: { clientX: event.clientX, clientY: event.clientY },
containerRect: this.containerBounds ?? this.getBoundingClientRect(),
metrics,
columns: this.columns,
widget,
rtl: this.rtl,
dragOffsetX: this.dragState.offsetX,
dragOffsetY: this.dragState.offsetY,
});
const placement = resolveWidgetPlacement(
activeWidgets,
widget.id,
{ x: coords.x, y: coords.y },
this.columns,
previousPosition,
);
if (placement) {
const updatedWidget = placement.widgets.find(item => item.id === widget.id);
this.dragState = {
...this.dragState,
currentPointer: { clientX: event.clientX, clientY: event.clientY },
lastPlacement: placement,
previousPosition: updatedWidget
? { id: updatedWidget.id, x: updatedWidget.x, y: updatedWidget.y, w: updatedWidget.w, h: updatedWidget.h }
: { id: widget.id, x: coords.x, y: coords.y, w: widget.w, h: widget.h },
};
this.previewWidgets = placement.widgets;
const previewWidget = placement.widgets.find(item => item.id === widget.id);
if (previewWidget) {
this.placeholderPosition = {
id: previewWidget.id,
x: previewWidget.x,
y: previewWidget.y,
w: previewWidget.w,
h: previewWidget.h,
};
} else {
this.placeholderPosition = { id: widget.id, x: coords.x, y: coords.y, w: widget.w, h: widget.h };
}
} else {
this.previewWidgets = null;
this.placeholderPosition = null;
}
this.requestUpdate();
};
private handleDragEnd = (event: PointerEvent): void => {
const dragState = this.dragState;
if (!dragState || event.pointerId !== dragState.pointerId) {
return;
}
const layoutSource = this.widgets;
this.previewWidgets = null;
// Always validate the final position, don't rely on lastPlacement from drag
const target = this.placeholderPosition ?? dragState.start;
const placement = resolveWidgetPlacement(
layoutSource,
dragState.widgetId,
{ x: target.x, y: target.y },
this.columns,
dragState.previousPosition,
);
if (placement) {
// Verify that the placement doesn't result in overlapping widgets
const finalWidget = placement.widgets.find(w => w.id === dragState.widgetId);
if (finalWidget) {
const hasOverlap = placement.widgets.some(w => {
if (w.id === dragState.widgetId) return false;
return (
finalWidget.x < w.x + w.w &&
finalWidget.x + finalWidget.w > w.x &&
finalWidget.y < w.y + w.h &&
finalWidget.y + finalWidget.h > w.y
);
});
if (!hasOverlap) {
this.commitPlacement(placement, dragState.widgetId, 'widget-move');
} else {
// Return to start position if overlap detected
this.widgets = this.widgets.map(widget =>
widget.id === dragState.widgetId ? { ...widget, x: dragState.start.x, y: dragState.start.y } : widget,
);
}
}
} else {
// Return to start position if no valid placement
this.widgets = this.widgets.map(widget =>
widget.id === dragState.widgetId ? { ...widget, x: dragState.start.x, y: dragState.start.y } : widget,
);
}
this.placeholderPosition = null;
this.dragState = null;
this.interactionActive = false;
this.releasePointerEvents();
};
private startResize(event: PointerEvent, widget: DashboardWidget, handler: 'e' | 's' | 'se'): void {
if (!this.editable || widget.noResize || widget.locked) {
return;
}
event.preventDefault();
event.stopPropagation();
this.ensureMetrics();
this.resizeState = {
widgetId: widget.id,
pointerId: event.pointerId,
handler,
startPointer: { clientX: event.clientX, clientY: event.clientY },
start: { id: widget.id, x: widget.x, y: widget.y, w: widget.w, h: widget.h },
startWidth: widget.w,
startHeight: widget.h,
lastPlacement: null,
};
this.interactionActive = true;
(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId);
document.addEventListener('pointermove', this.handleResizeMove);
document.addEventListener('pointerup', this.handleResizeEnd);
this.placeholderPosition = { id: widget.id, x: widget.x, y: widget.y, w: widget.w, h: widget.h };
}
private handleResizeMove = (event: PointerEvent): void => {
if (!this.resizeState) return;
const metrics = this.ensureMetrics();
const activeWidgets = this.widgets;
const widget = activeWidgets.find(item => item.id === this.resizeState!.widgetId);
if (!widget) return;
event.preventDefault();
const nextSize = computeResizeDimensions({
pointer: { clientX: event.clientX, clientY: event.clientY },
containerRect: this.containerBounds ?? this.getBoundingClientRect(),
metrics,
startWidth: this.resizeState.startWidth,
startHeight: this.resizeState.startHeight,
startPointer: this.resizeState.startPointer,
handler: this.resizeState.handler,
widget,
columns: this.columns,
});
const placement = resolveWidgetPlacement(
activeWidgets,
widget.id,
{ x: widget.x, y: widget.y, w: nextSize.width, h: nextSize.height },
this.columns,
this.resizeState.start,
);
if (placement) {
this.resizeState = { ...this.resizeState, lastPlacement: placement };
this.previewWidgets = placement.widgets;
const previewWidget = placement.widgets.find(item => item.id === widget.id);
if (previewWidget) {
this.placeholderPosition = {
id: previewWidget.id,
x: previewWidget.x,
y: previewWidget.y,
w: previewWidget.w,
h: previewWidget.h,
};
} else {
this.placeholderPosition = {
id: widget.id,
x: widget.x,
y: widget.y,
w: nextSize.width,
h: nextSize.height,
};
}
} else {
this.previewWidgets = null;
this.placeholderPosition = null;
}
this.requestUpdate();
};
private handleResizeEnd = (event: PointerEvent): void => {
const resizeState = this.resizeState;
if (!resizeState || event.pointerId !== resizeState.pointerId) {
return;
}
const layoutSource = this.widgets;
this.previewWidgets = null;
const placement =
resizeState.lastPlacement ??
resolveWidgetPlacement(
layoutSource,
resizeState.widgetId,
{
x: this.placeholderPosition?.x ?? resizeState.start.x,
y: this.placeholderPosition?.y ?? resizeState.start.y,
w: this.placeholderPosition?.w ?? resizeState.start.w,
h: this.placeholderPosition?.h ?? resizeState.start.h,
},
this.columns,
resizeState.start,
);
if (placement) {
this.commitPlacement(placement, resizeState.widgetId, 'widget-resize');
} else {
this.widgets = this.widgets.map(widget =>
widget.id === resizeState.widgetId ? { ...widget, w: resizeState.start.w, h: resizeState.start.h } : widget,
);
}
this.placeholderPosition = null;
this.resizeState = null;
this.interactionActive = false;
this.releasePointerEvents();
};
private handleHeaderKeydown(event: KeyboardEvent, widget: DashboardWidget): void {
if (!this.editable || widget.noMove || widget.locked) {
return;
}
const key = event.key;
const isResize = event.shiftKey;
let placement: PlacementResult | null = null;
if (isResize && ['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'].includes(key)) {
event.preventDefault();
const delta = key === 'ArrowRight' || key === 'ArrowDown' ? 1 : -1;
if (key === 'ArrowLeft' || key === 'ArrowRight') {
const maxWidth = widget.maxW ?? this.columns - widget.x;
const nextWidth = Math.max(widget.minW ?? 1, Math.min(maxWidth, widget.w + delta));
placement = resolveWidgetPlacement(
this.widgets,
widget.id,
{ x: widget.x, y: widget.y, w: nextWidth, h: widget.h },
this.columns,
);
} else {
const maxHeight = widget.maxH ?? Number.POSITIVE_INFINITY;
const nextHeight = Math.max(widget.minH ?? 1, Math.min(maxHeight, widget.h + delta));
placement = resolveWidgetPlacement(
this.widgets,
widget.id,
{ x: widget.x, y: widget.y, w: widget.w, h: nextHeight },
this.columns,
);
}
if (placement) {
this.commitPlacement(placement, widget.id, 'widget-resize');
}
return;
}
const moveMap: Record<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.previewWidgets = null;
this.widgets = result.widgets;
const subject = this.widgets.find(item => item.id === widgetId);
if (subject) {
this.dispatchEvent(
new CustomEvent(type, {
detail: {
widget: subject,
displaced: result.movedWidgets.filter(id => id !== widgetId),
swappedWith: result.swappedWith,
},
bubbles: true,
composed: true,
}),
);
}
}
public removeWidget(widgetId: string): void {
const target = this.widgets.find(widget => widget.id === widgetId);
if (!target) return;
this.widgets = this.widgets.filter(widget => widget.id !== widgetId);
this.dispatchEvent(
new CustomEvent('widget-remove', {
detail: { widget: target },
bubbles: true,
composed: true,
}),
);
}
public updateWidget(widgetId: string, updates: Partial<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 };
}
}