CDK Drag & Drop Compatibiliteit met LIT Elements
Inleiding
Angular CDK's drag & drop functionaliteit is een krachtige library voor het implementeren van sleep-en-neerzet interfaces. Echter, bij het combineren met LIT elements (webcomponenten) ontstaan er specifieke uitdagingen door de fundamentele verschillen in hoe Angular en webcomponenten DOM-elementen beheren.
Deze gids biedt een diepgaande analyse van deze compatibiliteitsproblemen en praktische oplossingen voor het succesvol integreren van DCR components met Angular CDK drag & drop.
Angular CDK Drag & Drop Fundamentals
Hoe CDK Drag & Drop Werkt
Angular CDK drag & drop werkt door DOM-elementen direct te manipuleren tijdens sleep operaties:
// Basis CDK drag & drop setup
import { CdkDragDrop, moveItemInArray, transferArrayItem } from '@angular/cdk/drag-drop';
@Component({
selector: 'app-task-board',
template: `
<div class="board">
<div class="column" cdkDropList [cdkDropListData]="todoTasks" (cdkDropListDropped)="drop($event)">
<div class="task-card" *ngFor="let task of todoTasks" cdkDrag>
{{ task.title }}
</div>
</div>
</div>
`,
styleUrls: ['./task-board.component.scss'],
})
export class TaskBoardComponent {
todoTasks = [
{ id: 1, title: 'Implementeer login systeem' },
{ id: 2, title: 'Design dashboard layout' },
{ id: 3, title: 'Schrijf unit tests' },
];
drop(event: CdkDragDrop<Task[]>) {
moveItemInArray(this.todoTasks, event.previousIndex, event.currentIndex);
}
}Interne CDK DOM Manipulatie
Tijdens een drag operatie voert CDK de volgende DOM manipulaties uit:
// Wat er intern gebeurt tijdens drag & drop:
// 1. Drag Start
const draggedElement = event.target.closest('[cdkDrag]');
const preview = draggedElement.cloneNode(true); // Clone voor preview
const placeholder = document.createElement('div'); // Placeholder element
// 2. Element Transformation
draggedElement.style.transform = 'translate3d(0, 0, 0)';
draggedElement.style.position = 'fixed';
draggedElement.style.zIndex = '1000';
// 3. Placeholder Insertion
draggedElement.parentNode.insertBefore(placeholder, draggedElement);
// 4. Preview Management
document.body.appendChild(preview);
// 5. Drop Operation
targetContainer.insertBefore(draggedElement, targetPosition);
placeholder.remove();
preview.remove();
// 6. Style Cleanup
draggedElement.style.transform = '';
draggedElement.style.position = '';
draggedElement.style.zIndex = '';Deze directe DOM manipulatie gebeurt buiten Angular's change detection en component lifecycle om.
Problemen met LIT Elements
1. Component State Verlies
LIT elements behouden hun interne state in JavaScript memory, niet in DOM attributes. CDK's DOM manipulatie kan dit verstoren:
// dcr-task-card.ts
import { LitElement, html } from 'lit';
import { customElement, property, state } from 'lit/decorators.js';
import styles from './dcr-task-card.scss';
@customElement('dcr-task-card')
export class DcrTaskCard extends LitElement {
static styles = styles;
@property({ type: Object })
task: Task | null = null;
@state()
private isExpanded = false; // Deze state kan verloren gaan
@state()
private lastInteraction = new Date(); // Dit ook
private interactionTimer?: number;
render() {
if (!this.task) return html`<div class="error">No task data</div>`;
return html`
<div class="task-card ${this.isExpanded ? 'expanded' : ''}">
<div class="header" @click=${this.toggleExpanded}>
<h3>${this.task.title}</h3>
<span class="toggle">${this.isExpanded ? '−' : '+'}</span>
</div>
${this.isExpanded ? this.renderDetails() : ''}
</div>
`;
}
private toggleExpanded() {
this.isExpanded = !this.isExpanded;
this.lastInteraction = new Date();
this.startInteractionTimer();
}
private renderDetails() {
return html`
<div class="details">
<p>${this.task?.description}</p>
<div class="metadata">
<span>Laatste interactie: ${this.lastInteraction.toLocaleTimeString()}</span>
</div>
</div>
`;
}
private startInteractionTimer() {
if (this.interactionTimer) {
clearTimeout(this.interactionTimer);
}
this.interactionTimer = window.setTimeout(() => {
// Auto-collapse na 30 seconden
this.isExpanded = false;
}, 30000);
}
}Probleem: Na een drag & drop operatie kunnen isExpanded, lastInteraction, en interactionTimer verloren gaan omdat:
- Het element wordt gecloneed voor de preview
- Het originele element wordt verplaatst in de DOM
- LIT's interne state blijft in memory van het originele element
- De nieuwe positie kan een "nieuw" element zijn vanuit Angular's perspectief
2. Event Listener Verlies
Event listeners die binnen LIT elements zijn geregistreerd kunnen worden onderbroken:
// dcr-interactive-card.ts
@customElement('dcr-interactive-card')
export class DcrInteractiveCard extends LitElement {
private resizeObserver?: ResizeObserver;
private documentClickHandler = this.handleDocumentClick.bind(this);
connectedCallback() {
super.connectedCallback();
// Deze observers/listeners kunnen verloren gaan na drag & drop
this.resizeObserver = new ResizeObserver(this.handleResize.bind(this));
this.resizeObserver.observe(this);
document.addEventListener('click', this.documentClickHandler);
}
disconnectedCallback() {
super.disconnectedCallback();
this.resizeObserver?.disconnect();
document.removeEventListener('click', this.documentClickHandler);
}
private handleResize(entries: ResizeObserverEntry[]) {
// Reageer op grootte wijzigingen
}
private handleDocumentClick(event: Event) {
// Sluit expanded state als er buiten wordt geklikt
if (!this.contains(event.target as Node)) {
this.isExpanded = false;
}
}
}3. CSS Custom Properties Sync Issues
CSS Custom Properties die door Angular worden gezet, kunnen niet synchroniseren na DOM manipulatie:
// Angular component
@Component({
template: `
<dcr-priority-card
[task]="task"
[style.--priority-color]="getPriorityColor(task.priority)"
[style.--due-date-urgency]="getDueDateUrgency(task.dueDate)"
cdkDrag
></dcr-priority-card>
`,
})
export class TaskListComponent {
getPriorityColor(priority: 'low' | 'medium' | 'high'): string {
const colors = {
low: '#4CAF50',
medium: '#FF9800',
high: '#F44336',
};
return colors[priority];
}
getDueDateUrgency(dueDate: Date): number {
const daysUntilDue = (dueDate.getTime() - Date.now()) / (1000 * 60 * 60 * 24);
return Math.max(0, Math.min(1, 1 - daysUntilDue / 7)); // 0-1 urgency scale
}
}// dcr-priority-card.ts
@customElement('dcr-priority-card')
export class DcrPriorityCard extends LitElement {
static styles = css`
:host {
--priority-color: #666;
--due-date-urgency: 0;
}
.card {
border-left: 4px solid var(--priority-color);
background: linear-gradient(90deg, rgba(255, 0, 0, var(--due-date-urgency)) 0%, transparent 20%);
}
`;
render() {
return html`
<div class="card">
<!-- Card content -->
</div>
`;
}
}Probleem: Na drag & drop zijn de CSS custom properties mogelijk niet meer correct ingesteld.
Praktische Oplossingsstrategieën
1. State Persistence via Attributes
Implementeer automatische state backup naar HTML attributes:
// dcr-stateful-task-card.ts
import { LitElement, html, PropertyValues } from 'lit';
import { customElement, property, state } from 'lit/decorators.js';
import styles from './dcr-stateful-task-card.scss';
@customElement('dcr-stateful-task-card')
export class DcrStatefulTaskCard extends LitElement {
static styles = styles;
@property({ type: Object })
task: Task | null = null;
// State met automatische attribute backup
@state()
private _isExpanded = false;
@state()
private _lastInteraction = new Date();
// Getters/setters voor automatic persistence
get isExpanded(): boolean {
return this._isExpanded;
}
set isExpanded(value: boolean) {
this._isExpanded = value;
this.setAttribute('data-expanded', value.toString());
}
get lastInteraction(): Date {
return this._lastInteraction;
}
set lastInteraction(value: Date) {
this._lastInteraction = value;
this.setAttribute('data-last-interaction', value.toISOString());
}
connectedCallback() {
super.connectedCallback();
this.restoreStateFromAttributes();
this.startPeriodicStateBackup();
}
disconnectedCallback() {
super.disconnectedCallback();
this.stopPeriodicStateBackup();
}
private restoreStateFromAttributes() {
// Herstel state van attributes bij reconnection
const expandedAttr = this.getAttribute('data-expanded');
if (expandedAttr === 'true') {
this._isExpanded = true;
}
const lastInteractionAttr = this.getAttribute('data-last-interaction');
if (lastInteractionAttr) {
try {
this._lastInteraction = new Date(lastInteractionAttr);
} catch (e) {
console.warn('Could not parse last interaction date');
}
}
}
private backupTimer?: number;
private startPeriodicStateBackup() {
// Backup kritieke state elke seconde
this.backupTimer = window.setInterval(() => {
this.backupStateToAttributes();
}, 1000);
}
private stopPeriodicStateBackup() {
if (this.backupTimer) {
clearInterval(this.backupTimer);
this.backupTimer = undefined;
}
}
private backupStateToAttributes() {
this.setAttribute('data-expanded', this.isExpanded.toString());
this.setAttribute('data-last-interaction', this.lastInteraction.toISOString());
// Backup task ID voor referentie
if (this.task?.id) {
this.setAttribute('data-task-id', this.task.id.toString());
}
}
private toggleExpanded() {
this.isExpanded = !this.isExpanded;
this.lastInteraction = new Date();
this.dispatchEvent(
new CustomEvent('expand-changed', {
detail: {
expanded: this.isExpanded,
taskId: this.task?.id,
},
bubbles: true,
composed: true,
})
);
}
render() {
if (!this.task) return html`<div class="error">No task data</div>`;
return html`
<div class="task-card ${this.isExpanded ? 'expanded' : ''}">
<div class="header" @click=${this.toggleExpanded}>
<h3>${this.task.title}</h3>
<span class="toggle">${this.isExpanded ? '−' : '+'}</span>
</div>
${this.isExpanded ? this.renderDetails() : ''}
</div>
`;
}
private renderDetails() {
return html`
<div class="details">
<p>${this.task?.description || 'Geen beschrijving'}</p>
<div class="metadata">
<span>Laatste interactie: ${this.lastInteraction.toLocaleTimeString('nl-BE')}</span>
</div>
</div>
`;
}
}2. Enhanced Angular Integration
Maak Angular componenten die actief samenwerken met DCR elements:
// enhanced-task-board.component.ts
import { Component, AfterViewInit, ViewChildren, QueryList, ElementRef, NgZone } from '@angular/core';
import { CdkDragDrop, CdkDrag } from '@angular/cdk/drag-drop';
interface Task {
id: number;
title: string;
description: string;
priority: 'low' | 'medium' | 'high';
dueDate: Date;
columnId: string;
}
@Component({
selector: 'app-enhanced-task-board',
template: `
<div class="board">
<div
class="column"
*ngFor="let column of columns"
cdkDropList
[id]="column.id"
[cdkDropListData]="getTasksForColumn(column.id)"
(cdkDropListDropped)="drop($event)"
(cdkDropListEntered)="onDragEnter($event)"
(cdkDropListExited)="onDragExit($event)"
>
<h2>{{ column.title }}</h2>
<dcr-stateful-task-card
*ngFor="let task of getTasksForColumn(column.id); trackBy: trackByTaskId"
[task]="task"
[attr.task-id]="task.id"
[attr.column-id]="column.id"
[style.--priority-color]="getPriorityColor(task.priority)"
[style.--due-date-urgency]="getDueDateUrgency(task.dueDate)"
(expand-changed)="onTaskExpandChanged($event)"
cdkDrag
[cdkDragData]="task"
></dcr-stateful-task-card>
</div>
</div>
`,
styleUrls: ['./enhanced-task-board.component.scss'],
})
export class EnhancedTaskBoardComponent implements AfterViewInit {
@ViewChildren(CdkDrag) dragElements!: QueryList<CdkDrag>;
columns = [
{ id: 'todo', title: 'Te doen' },
{ id: 'in-progress', title: 'Bezig' },
{ id: 'done', title: 'Klaar' },
];
tasks: Task[] = [
{
id: 1,
title: 'Implementeer login systeem',
description: 'OAuth2 integratie met Google en Microsoft',
priority: 'high',
dueDate: new Date(Date.now() + 2 * 24 * 60 * 60 * 1000),
columnId: 'todo',
},
{
id: 2,
title: 'Design dashboard layout',
description: 'Wireframes en prototype in Figma',
priority: 'medium',
dueDate: new Date(Date.now() + 5 * 24 * 60 * 60 * 1000),
columnId: 'in-progress',
},
];
private draggedElement: HTMLElement | null = null;
private originalStyles: Partial<CSSStyleDeclaration> = {};
constructor(private ngZone: NgZone) {}
ngAfterViewInit() {
this.setupDragHandlers();
}
private setupDragHandlers() {
this.dragElements.forEach((dragRef) => {
// Enhanced drag start
dragRef.started.subscribe((event) => {
this.onEnhancedDragStart(event);
});
// Enhanced drag end
dragRef.ended.subscribe((event) => {
this.onEnhancedDragEnd(event);
});
});
}
private onEnhancedDragStart(event: any) {
this.draggedElement = event.source.element.nativeElement;
// Backup original styles
this.originalStyles = {
transform: this.draggedElement!.style.transform,
position: this.draggedElement!.style.position,
zIndex: this.draggedElement!.style.zIndex,
};
// Backup component state
this.backupElementState(this.draggedElement!);
console.log('Enhanced drag started for element:', this.draggedElement);
}
private onEnhancedDragEnd(event: any) {
if (this.draggedElement) {
// Restore component state
this.restoreElementState(this.draggedElement);
// Ensure styles are cleaned up
this.ngZone.runOutsideAngular(() => {
requestAnimationFrame(() => {
this.cleanupDragStyles();
});
});
}
}
private backupElementState(element: HTMLElement) {
const dcrElement = element.querySelector('dcr-stateful-task-card') || element;
if (dcrElement) {
// Force state backup
(dcrElement as any).backupStateToAttributes?.();
}
}
private restoreElementState(element: HTMLElement) {
const dcrElement = element.querySelector('dcr-stateful-task-card') || element;
if (dcrElement) {
// Force state restore
(dcrElement as any).restoreStateFromAttributes?.();
}
}
private cleanupDragStyles() {
if (this.draggedElement) {
// Restore original styles
Object.assign(this.draggedElement.style, this.originalStyles);
this.draggedElement = null;
this.originalStyles = {};
}
}
drop(event: CdkDragDrop<Task[]>) {
const task = event.item.data as Task;
const newColumnId = event.container.id;
console.log(`Moving task ${task.id} to column ${newColumnId}`);
// Update task data
task.columnId = newColumnId;
// Force UI update buiten Angular zone
this.ngZone.runOutsideAngular(() => {
requestAnimationFrame(() => {
this.ngZone.run(() => {
// Trigger change detection na DOM stabilisatie
this.forceElementSync(event.item.element.nativeElement);
});
});
});
}
private forceElementSync(draggedElement: HTMLElement) {
const dcrElement = draggedElement.querySelector('dcr-stateful-task-card');
if (dcrElement) {
const taskId = draggedElement.getAttribute('task-id');
const task = this.tasks.find((t) => t.id.toString() === taskId);
if (task) {
// Force property updates
(dcrElement as any).task = { ...task };
// Update visual properties
const priorityColor = this.getPriorityColor(task.priority);
const dueDateUrgency = this.getDueDateUrgency(task.dueDate);
dcrElement.style.setProperty('--priority-color', priorityColor);
dcrElement.style.setProperty('--due-date-urgency', dueDateUrgency.toString());
}
}
}
onDragEnter(event: any) {
console.log('Drag entered column:', event.container.id);
}
onDragExit(event: any) {
console.log('Drag exited column:', event.container.id);
}
onTaskExpandChanged(event: CustomEvent) {
console.log('Task expand changed:', event.detail);
}
getTasksForColumn(columnId: string): Task[] {
return this.tasks.filter((task) => task.columnId === columnId);
}
trackByTaskId(index: number, task: Task): number {
return task.id;
}
getPriorityColor(priority: 'low' | 'medium' | 'high'): string {
const colors = {
low: 'rgb(76, 175, 80)', // Groen
medium: 'rgb(255, 152, 0)', // Oranje
high: 'rgb(244, 67, 54)', // Rood
};
return colors[priority];
}
getDueDateUrgency(dueDate: Date): number {
const daysUntilDue = (dueDate.getTime() - Date.now()) / (1000 * 60 * 60 * 24);
return Math.max(0, Math.min(1, 1 - daysUntilDue / 7)); // 0-1 urgency scale over 7 dagen
}
}3. CSS Custom Properties Management
Implementeer een service voor het beheren van CSS custom properties die overleven DOM manipulatie:
// css-properties.service.ts
import { Injectable } from '@angular/core';
interface ElementStyleBackup {
elementId: string;
properties: Map<string, string>;
lastUpdate: Date;
}
@Injectable({
providedIn: 'root',
})
export class CssPropertiesService {
private backups = new Map<string, ElementStyleBackup>();
backupElementStyles(element: HTMLElement, elementId: string) {
const computedStyles = getComputedStyle(element);
const properties = new Map<string, string>();
// Backup alle CSS custom properties
for (let i = 0; i < computedStyles.length; i++) {
const propertyName = computedStyles[i];
if (propertyName.startsWith('--')) {
properties.set(propertyName, computedStyles.getPropertyValue(propertyName));
}
}
// Backup inline styles
for (let i = 0; i < element.style.length; i++) {
const propertyName = element.style[i];
if (propertyName.startsWith('--')) {
properties.set(propertyName, element.style.getPropertyValue(propertyName));
}
}
this.backups.set(elementId, {
elementId,
properties,
lastUpdate: new Date(),
});
}
restoreElementStyles(element: HTMLElement, elementId: string) {
const backup = this.backups.get(elementId);
if (!backup) return;
backup.properties.forEach((value, property) => {
element.style.setProperty(property, value);
});
}
updateElementProperty(elementId: string, property: string, value: string) {
const backup = this.backups.get(elementId);
if (backup) {
backup.properties.set(property, value);
backup.lastUpdate = new Date();
}
}
clearBackup(elementId: string) {
this.backups.delete(elementId);
}
getAllBackups(): ElementStyleBackup[] {
return Array.from(this.backups.values());
}
}Gebruik deze service in je components:
// task-board-with-css-management.component.ts
@Component({
selector: 'app-task-board-with-css-management',
template: `
<dcr-priority-card
#taskCard
[task]="task"
[attr.element-id]="getElementId(task)"
[style.--priority-color]="getPriorityColor(task.priority)"
[style.--due-date-urgency]="getDueDateUrgency(task.dueDate)"
cdkDrag
></dcr-priority-card>
`,
})
export class TaskBoardWithCssManagementComponent {
constructor(private cssService: CssPropertiesService) {}
onDragStart(task: Task, element: HTMLElement) {
const elementId = this.getElementId(task);
this.cssService.backupElementStyles(element, elementId);
}
onDragEnd(task: Task, element: HTMLElement) {
const elementId = this.getElementId(task);
// Restore styles na DOM manipulatie
setTimeout(() => {
this.cssService.restoreElementStyles(element, elementId);
}, 0);
}
getElementId(task: Task): string {
return `task-${task.id}`;
}
getPriorityColor(priority: string): string {
// ... implementatie
}
getDueDateUrgency(dueDate: Date): number {
// ... implementatie
}
}4. Advanced LIT Element Drag Integration
Maak LIT elements die specifiek zijn ontworpen voor drag & drop compatibiliteit:
// dcr-draggable-card.ts
import { LitElement, html, css, PropertyValues } from 'lit';
import { customElement, property, state, query } from 'lit/decorators.js';
interface DragState {
isDragging: boolean;
isPreview: boolean;
startPosition: { x: number; y: number };
currentPosition: { x: number; y: number };
}
@customElement('dcr-draggable-card')
export class DcrDraggableCard extends LitElement {
static styles = css`
:host {
--drag-opacity: 1;
--drag-scale: 1;
--drag-rotation: 0deg;
display: block;
opacity: var(--drag-opacity);
transform: scale(var(--drag-scale)) rotate(var(--drag-rotation));
transition: all 0.2s ease;
}
:host([dragging]) {
--drag-opacity: 0.8;
--drag-scale: 1.05;
--drag-rotation: 2deg;
}
:host([preview]) {
--drag-opacity: 0.9;
--drag-scale: 0.95;
pointer-events: none;
}
.card {
background: var(--card-background, white);
border: 1px solid var(--card-border, #ddd);
border-radius: 8px;
padding: 16px;
cursor: grab;
}
.card:active {
cursor: grabbing;
}
.drag-handle {
position: absolute;
top: 8px;
right: 8px;
width: 20px;
height: 20px;
background: var(--handle-color, #999);
border-radius: 50%;
cursor: grab;
}
`;
@property({ type: Object })
data: any = null;
@property({ type: String, reflect: true })
elementId = '';
@state()
private dragState: DragState = {
isDragging: false,
isPreview: false,
startPosition: { x: 0, y: 0 },
currentPosition: { x: 0, y: 0 },
};
@query('.card')
private cardElement!: HTMLElement;
// Mutation observer voor DOM wijzigingen
private mutationObserver?: MutationObserver;
// Backup van essentiële state
private stateBackup: any = {};
connectedCallback() {
super.connectedCallback();
this.setupMutationObserver();
this.restoreFromBackup();
this.setupDragEventListeners();
}
disconnectedCallback() {
super.disconnectedCallback();
this.cleanupMutationObserver();
this.cleanupDragEventListeners();
}
private setupMutationObserver() {
this.mutationObserver = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
if (mutation.type === 'attributes' && mutation.attributeName === 'style') {
// Detect style wijzigingen van CDK
this.onStyleMutation();
}
});
});
this.mutationObserver.observe(this, {
attributes: true,
attributeFilter: ['style', 'class'],
});
}
private cleanupMutationObserver() {
this.mutationObserver?.disconnect();
}
private onStyleMutation() {
// Detecteer CDK drag styles
const transform = this.style.transform;
const position = this.style.position;
if (position === 'fixed' && transform.includes('translate3d')) {
this.dragState.isDragging = true;
this.setAttribute('dragging', '');
} else if (this.dragState.isDragging && position !== 'fixed') {
this.dragState.isDragging = false;
this.removeAttribute('dragging');
this.onDragEnd();
}
}
private setupDragEventListeners() {
this.addEventListener('dragstart', this.onDragStart.bind(this));
this.addEventListener('dragend', this.onDragEnd.bind(this));
}
private cleanupDragEventListeners() {
this.removeEventListener('dragstart', this.onDragStart.bind(this));
this.removeEventListener('dragend', this.onDragEnd.bind(this));
}
private onDragStart() {
this.backupState();
this.dragState.isDragging = true;
this.dispatchEvent(
new CustomEvent('dcr-drag-start', {
detail: {
element: this,
data: this.data,
elementId: this.elementId,
},
bubbles: true,
composed: true,
})
);
}
private onDragEnd() {
this.dragState.isDragging = false;
// Herstel state na drag
setTimeout(() => {
this.restoreState();
}, 100);
this.dispatchEvent(
new CustomEvent('dcr-drag-end', {
detail: {
element: this,
data: this.data,
elementId: this.elementId,
},
bubbles: true,
composed: true,
})
);
}
private backupState() {
this.stateBackup = {
data: this.data ? { ...this.data } : null,
elementId: this.elementId,
dragState: { ...this.dragState },
timestamp: Date.now(),
};
// Backup naar sessionStorage voor ultra-veiligheid
if (this.elementId) {
sessionStorage.setItem(`dcr-card-backup-${this.elementId}`, JSON.stringify(this.stateBackup));
}
}
private restoreState() {
if (this.stateBackup.data && this.stateBackup.timestamp) {
this.data = this.stateBackup.data;
this.elementId = this.stateBackup.elementId;
this.requestUpdate();
}
}
private restoreFromBackup() {
if (this.elementId) {
const backupData = sessionStorage.getItem(`dcr-card-backup-${this.elementId}`);
if (backupData) {
try {
const backup = JSON.parse(backupData);
const age = Date.now() - backup.timestamp;
// Alleen herstellen als backup minder dan 1 minuut oud is
if (age < 60000) {
this.stateBackup = backup;
this.restoreState();
} else {
// Oude backup opruimen
sessionStorage.removeItem(`dcr-card-backup-${this.elementId}`);
}
} catch (e) {
console.warn('Could not restore backup for element:', this.elementId);
}
}
}
}
willUpdate(changedProperties: PropertyValues) {
// Auto-backup bij property wijzigingen
if (changedProperties.has('data') || changedProperties.has('elementId')) {
this.backupState();
}
}
render() {
return html`
<div class="card">
<div class="drag-handle"></div>
<slot></slot>
${this.data
? html`
<div class="data-display">
<pre>${JSON.stringify(this.data, null, 2)}</pre>
</div>
`
: ''} ${this.dragState.isDragging ? html` <div class="drag-indicator">Slepen...</div> ` : ''}
</div>
`;
}
}Testing Drag & Drop met LIT Elements
Unit Testing Strategy
// dcr-draggable-card.spec.ts
import { expect, fixture, html, oneEvent } from '@open-wc/testing';
import { DcrDraggableCard } from './dcr-draggable-card';
describe('DcrDraggableCard Drag & Drop', () => {
it('should maintain state during simulated drag operation', async () => {
const el = await fixture<DcrDraggableCard>(html`
<dcr-draggable-card element-id="test-card-1" .data=${{ id: 1, title: 'Test Task' }}></dcr-draggable-card>
`);
// Verify initial state
expect(el.data).to.deep.equal({ id: 1, title: 'Test Task' });
expect(el.elementId).to.equal('test-card-1');
// Simulate drag start
const dragStartEvent = new Event('dragstart');
el.dispatchEvent(dragStartEvent);
// Simulate CDK DOM manipulation
el.style.position = 'fixed';
el.style.transform = 'translate3d(100px, 50px, 0px)';
el.style.zIndex = '1000';
// Wait for mutation observer
await new Promise((resolve) => setTimeout(resolve, 50));
// Verify dragging state
expect(el.hasAttribute('dragging')).to.be.true;
// Simulate data corruption (what CDK might cause)
(el as any).data = null;
// Simulate drag end
el.style.position = '';
el.style.transform = '';
el.style.zIndex = '';
const dragEndEvent = new Event('dragend');
el.dispatchEvent(dragEndEvent);
// Wait for state restoration
await new Promise((resolve) => setTimeout(resolve, 150));
// Verify state was restored
expect(el.data).to.deep.equal({ id: 1, title: 'Test Task' });
expect(el.hasAttribute('dragging')).to.be.false;
});
it('should emit custom drag events', async () => {
const el = await fixture<DcrDraggableCard>(html`
<dcr-draggable-card element-id="test-card-2"></dcr-draggable-card>
`);
// Listen for custom drag start event
const dragStartPromise = oneEvent(el, 'dcr-drag-start');
// Trigger drag start
el.dispatchEvent(new Event('dragstart'));
const dragStartEvent = await dragStartPromise;
expect(dragStartEvent.detail.elementId).to.equal('test-card-2');
});
it('should restore from sessionStorage backup', async () => {
// Setup backup in sessionStorage
const backupData = {
data: { id: 99, title: 'Restored Task' },
elementId: 'restored-card',
timestamp: Date.now(),
};
sessionStorage.setItem('dcr-card-backup-restored-card', JSON.stringify(backupData));
// Create element with same ID
const el = await fixture<DcrDraggableCard>(html`
<dcr-draggable-card element-id="restored-card"></dcr-draggable-card>
`);
await el.updateComplete;
// Verify data was restored from backup
expect(el.data).to.deep.equal({ id: 99, title: 'Restored Task' });
// Cleanup
sessionStorage.removeItem('dcr-card-backup-restored-card');
});
});Integration Testing
// task-board.integration.spec.ts
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { DragDropModule, CdkDragDrop } from '@angular/cdk/drag-drop';
import { Component, CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
@Component({
template: `
<div cdkDropList (cdkDropListDropped)="onDrop($event)">
<dcr-draggable-card *ngFor="let task of tasks" [data]="task" [element-id]="'task-' + task.id" cdkDrag>
{{ task.title }}
</dcr-draggable-card>
</div>
`,
})
class TestHostComponent {
tasks = [
{ id: 1, title: 'Task 1' },
{ id: 2, title: 'Task 2' },
];
onDrop(event: CdkDragDrop<any[]>) {
// Test implementation
}
}
describe('Task Board Integration', () => {
let component: TestHostComponent;
let fixture: ComponentFixture<TestHostComponent>;
beforeEach(() => {
TestBed.configureTestingModule({
declarations: [TestHostComponent],
imports: [DragDropModule],
schemas: [CUSTOM_ELEMENTS_SCHEMA],
});
fixture = TestBed.createComponent(TestHostComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should maintain DCR element state after drag and drop', async () => {
// Get DCR elements
const dcrElements = fixture.nativeElement.querySelectorAll('dcr-draggable-card');
expect(dcrElements.length).toBe(2);
// Verify initial state
expect(dcrElements[0].data).to.deep.equal({ id: 1, title: 'Task 1' });
// Simulate drag and drop operation
// (This would involve more complex DOM manipulation in a real test)
// Verify state preservation after drag
await new Promise((resolve) => setTimeout(resolve, 200));
expect(dcrElements[0].data).to.deep.equal({ id: 1, title: 'Task 1' });
});
});Best Practices
1. Proactive State Management
// Implementeer state management die anticipateert op DOM manipulatie
@customElement('dcr-resilient-card')
export class DcrResilientCard extends LitElement {
// Gebruik meerdere backup strategieën
private backupStrategies = [
'attributes', // HTML attributes
'sessionStorage', // Browser storage
'eventBus', // Global event bus
'parentRef', // Parent component reference
];
backupState() {
this.backupStrategies.forEach((strategy) => {
switch (strategy) {
case 'attributes':
this.backupToAttributes();
break;
case 'sessionStorage':
this.backupToStorage();
break;
case 'eventBus':
this.backupToEventBus();
break;
case 'parentRef':
this.backupToParent();
break;
}
});
}
}2. Performance Optimizations
// Optimaliseer voor frequent drag & drop operaties
@customElement('dcr-optimized-card')
export class DcrOptimizedCard extends LitElement {
// Debounce state backup om prestaties te verbeteren
private backupDebounce = this.debounce(() => {
this.performStateBackup();
}, 100);
willUpdate(changedProperties: PropertyValues) {
if (this.shouldBackupState(changedProperties)) {
this.backupDebounce();
}
}
private shouldBackupState(changedProperties: PropertyValues): boolean {
// Alleen backup bij relevante wijzigingen
return changedProperties.has('data') || changedProperties.has('criticalState');
}
private debounce(func: Function, wait: number) {
let timeout: number;
return (...args: any[]) => {
clearTimeout(timeout);
timeout = window.setTimeout(() => func.apply(this, args), wait);
};
}
}3. Error Recovery
// Implementeer robuuste error recovery
@customElement('dcr-robust-card')
export class DcrRobustCard extends LitElement {
connectedCallback() {
super.connectedCallback();
this.setupErrorRecovery();
}
private setupErrorRecovery() {
// Global error handler voor onverwachte state loss
window.addEventListener('unhandledrejection', (event) => {
if (this.isStateCorrupted()) {
console.warn('State corruption detected, attempting recovery');
this.attemptStateRecovery();
}
});
// Periodic health check
setInterval(() => {
this.performHealthCheck();
}, 5000);
}
private isStateCorrupted(): boolean {
return !this.data || !this.elementId || this.hasInconsistentState();
}
private hasInconsistentState(): boolean {
// Check voor inconsistenties tussen properties en attributes
const attrData = this.getAttribute('data-backup');
if (attrData) {
try {
const parsedData = JSON.parse(attrData);
return JSON.stringify(this.data) !== JSON.stringify(parsedData);
} catch (e) {
return true;
}
}
return false;
}
private attemptStateRecovery() {
// Multi-tier recovery strategy
if (this.recoverFromAttributes()) return;
if (this.recoverFromStorage()) return;
if (this.recoverFromParent()) return;
// Last resort: emit recovery event
this.dispatchEvent(
new CustomEvent('dcr-recovery-needed', {
detail: { elementId: this.elementId },
bubbles: true,
composed: true,
})
);
}
}Conclusie
De integratie van LIT elements met Angular CDK drag & drop vereist een zorgvuldige aanpak die rekening houdt met de fundamentele verschillen tussen Angular's component model en webcomponent architectuur. Door de implementatie van robuuste state persistence, proactieve backup strategieën, en comprehensive testing, kun je DCR components bouwen die naadloos werken in complexe drag & drop scenarios.
Belangrijkste strategieën voor succesvolle integratie:
- Multi-tier State Backup: Gebruik meerdere backup mechanismen (attributes, storage, events)
- Proactive Monitoring: Implementeer mutation observers en health checks
- Graceful Recovery: Bouw fallback mechanismen voor state recovery
- Performance Optimization: Debounce en optimaliseer backup operaties
- Comprehensive Testing: Test alle drag & drop scenarios expliciet
- Enhanced Angular Integration: Maak Angular componenten die actief samenwerken met LIT elements
Door deze principes toe te passen, kun je robuuste, gebruiksvriendelijke interfaces bouwen die de kracht van beide technologieën benutten zonder de typische compatibiliteitsproblemen.