All files / src/layout CardStack.ts

96.26% Statements 129/134
83.87% Branches 26/31
100% Functions 10/10
96.26% Lines 129/134

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135125x 127x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 125x 127x 127x 127x 127x 127x 127x 127x 127x 127x 127x 127x 127x 127x 127x 127x 127x 127x 127x 125x 125x 125x 125x 125x 125x 125x 125x 105x 105x 105x 105x 105x 105x 105x 105x 105x       125x 28x 70x 28x 125x 9x 9x 9x 9x 125x 77x 77x 179x 179x 179x 179x 179x 150x 179x 77x 125x 46x 46x 46x 125x 123x 15x 15x 34x 50x 50x 50x 50x 15x 30x 30x 15x 15x 15x 15x 15x 15x 33x 123x 2x 5x   5x 5x 125x 4x 4x 4x 4x 4x 4x 4x 4x 4x 4x 4x 4x 4x 4x 4x 4x 4x 4x 4x 4x 4x 4x 4x 4x   4x 4x 4x 125x 125x 125x 125x  
import { css, html, TemplateResult } from 'lit';
import { property } from 'lit/decorators.js';
import { RapidElement } from '../RapidElement';
import { CustomEventType } from '../interfaces';
 
/**
 * A vertical stack of temba-cards that can be reordered by dragging their
 * headers. Owns the DOM move on drop and re-emits temba-order-changed with
 * the full id list so hosts only deal in ids. An initial order can be
 * passed via the `order` attribute (JSON array of child ids).
 */
export class CardStack extends RapidElement {
  static get styles() {
    return css`
      :host {
        display: block;
      }
    `;
  }
 
  @property({ type: Array })
  order: string[] = [];
 
  /** Vertical gap between cards — the layout's one spacing unit, so
   * card-to-card spacing matches the gap between the main view and the
   * card column. */
  @property({ type: String })
  gap = 'var(--layout-spacing, 8px)';
 
  private getAssigned(): Element[] {
    // resolve through the shadow slot so cards forwarded from a wrapper
    // component's slot (e.g. temba-card-layout) are seen too
    const slot = this.shadowRoot?.querySelector('slot');
    return slot
      ? slot.assignedElements({ flatten: true })
      : Array.from(this.children);
  }
 
  private getCards(): HTMLElement[] {
    return this.getAssigned().filter(
      (ele) => ele.classList.contains('sortable') && ele.id
    ) as HTMLElement[];
  }
 
  public getIds(): string[] {
    return this.getCards().map((ele) => ele.id);
  }
 
  private handleSlotChange() {
    // cards are expected to carry .sortable for SortableList; add it so
    // consumers don't have to remember. Only actual cards — an incidental
    // id'd element passing through the slot shouldn't become draggable.
    this.getAssigned().forEach((ele) => {
      if (ele.tagName === 'TEMBA-CARD' && ele.id) {
        ele.classList.add('sortable');
      }
    });
    this.applyOrder();
  }
 
  protected updated(changes: Map<PropertyKey, unknown>): void {
    super.updated(changes);
    if (changes.has('order')) {
      this.applyOrder();
    }
  }
 
  private applyOrder() {
    if (!this.order || this.order.length === 0) {
      return;
    }
 
    const cards = this.getCards();
    const listed = this.order
      .map((id) => cards.find((card) => card.id === id))
      .filter(Boolean) as HTMLElement[];
    const unlisted = cards.filter((card) => !listed.includes(card));
    const desired = [...listed, ...unlisted];
 
    if (desired.every((card, idx) => card === cards[idx])) {
      return;
    }
 
    // move within whatever parent actually holds the cards (this element,
    // or the wrapper host when cards arrive through a forwarded slot)
    desired.forEach((card) => card.parentElement?.appendChild(card));
  }
 
  private handleOrderChanged(event: CustomEvent) {
    // the inner sortable list reports a swap but leaves the DOM alone;
    // perform the move here and surface the resulting id order instead
    event.stopPropagation();
 
    const [fromIdx, toIdx] = event.detail.swap;
    const cards = this.getCards();
    const moving = cards[fromIdx];
    const target = cards[toIdx];
    if (!moving || !target || moving === target) {
      return;
    }
 
    if (fromIdx < toIdx) {
      target.insertAdjacentElement('afterend', moving);
    } else {
      target.insertAdjacentElement('beforebegin', moving);
    }
 
    this.order = this.getIds();
    this.fireCustomEvent(CustomEventType.OrderChanged, { ids: this.order });
  }
 
  /**
   * The ghost is a deep clone appended to document.body, sized to the
   * original card — so an open card drags as an open card. Cloned panels
   * re-render from the store's cache; clip anything still settling.
   */
  private prepareGhost = (ghost: HTMLElement) => {
    ghost.removeAttribute('id');
    ghost.style.overflow = 'hidden';
  };
 
  public render(): TemplateResult {
    return html`
      <temba-sortable-list
        dragHandle="card-header"
        gap=${this.gap}
        .prepareGhost=${this.prepareGhost}
        @temba-order-changed=${this.handleOrderChanged}
      >
        <slot @slotchange=${this.handleSlotChange}></slot>
      </temba-sortable-list>
    `;
  }
}