Skip to content

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:

typescript
// 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:

javascript
// 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:

typescript
// 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:

  1. Het element wordt gecloneed voor de preview
  2. Het originele element wordt verplaatst in de DOM
  3. LIT's interne state blijft in memory van het originele element
  4. 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:

typescript
// 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:

typescript
// 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
  }
}
typescript
// 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:

typescript
// 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:

typescript
// 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:

typescript
// 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:

typescript
// 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:

typescript
// 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

typescript
// 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

typescript
// 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

typescript
// 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

typescript
// 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

typescript
// 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:

  1. Multi-tier State Backup: Gebruik meerdere backup mechanismen (attributes, storage, events)
  2. Proactive Monitoring: Implementeer mutation observers en health checks
  3. Graceful Recovery: Bouw fallback mechanismen voor state recovery
  4. Performance Optimization: Debounce en optimaliseer backup operaties
  5. Comprehensive Testing: Test alle drag & drop scenarios expliciet
  6. 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.