Reusable Cards

Dated May 3, 2024; last modified on Fri, 03 May 2024

Context

How can I reuse code in these user experiences?

Current card templates. TL: editable card owned by the user.
TR: the overall modal experience. BL: a publicly viewable card. BR: a
card owned by the user but is in the trash.

Current card templates. TL: editable card owned by the user. TR: the overall modal experience. BL: a publicly viewable card. BR: a card owned by the user but is in the trash.

The current API surface for the different types of UIs is:

interface EditableCardViewer {
  displayNewCard(): void;
  displayFullCard(cardId: string): void;
  renderCard(card: Partial<ICard> | null): void;

  fetchCard(caller: () => Promise<Partial<ICard> | null>): void;
  fetchPreviousCard(): void;
  fetchNextCard(): void;

  updateStreakBar(streak: IStreak): void;

  handleInputChange(elementId: string): void;
  handleCardUrgencyChange(): void;
  handleTagsInputChange(ev: KeyboardEvent): void;

  displayRawCardDescription(): void;
  insertTabsIfNecessary(ev: KeyboardEvent): void;

  saveCard(renderAfterSave: Boolean = true): void;
  moveCardToTrash(): void;
  restoreCardFromTrash(cardId: string, cardUrgency: number): void;

  toggleOption(elementId: "reviewModeToggle" | "card_is_public_toggle"): void;
  makeInvisible(elementId: string): void;
  colorUrgencyQuartiles(quartiles: number[]): void;

  suggestNewTags(tagsInputElement: HTMLInputElement): void;
  removeTagSuggestions(): void;
  updateTagsButtons(newTag: string): void;
  removeTagFromCard(tag: string): void;
}

interface PublicCardViewer {
  displayFullCard(cardId: string): void;
  renderCard(card: Partial<ICard> | null): void;

  fetchPreviousCard(): void;
  fetchNextCard(): void;

  handleSearchInputChange(): void;

  flagCard(reason: "markedForReview" | "markedAsDuplicate"): void;
  copyCardToOwnCollection();
}

interface TrashedCardViewer {
  renderTrashedCard(card: Partial<ICard> | null);

  fetchPreviousCard(): void;
  fetchNextCard(): void;

  modifyTrash(endpoint: "/delete-card" | "/restore-from-trash"): void;
}

The web components v0 has:

class CardViewer extends LitElement {
  @property({type: Object})
  protected card: Card | null = null;

  // If card changed and is non-null, show the modal.
  updated(changedProperties: Map<string, any>): void {...}

  protected updateCarouselCursor(direction: CardsCarouselUpdateCursorDirection): void {...}

  protected closeDialog(): void {...}

  // Throws error as it is supposed to be overriden.
  protected renderCard(): TemplateResult {...}

  // Calls `renderCard` and wraps the result in a <dialog> element.
  render(): TemplateResult {...}

  // Style dialog, backdrop, and code blocks.
  static styles = [ ... ]
}

@customElement('public-card-viewer')
class PublicCardViewer extends CardViewer {
  @property({ type: Object})
  protected card: PublicCardResult = null;

  private flagCard(reason: FlagReason): void {...}

  // Can use `CardViewer.prototype.*` properties.
  protected renderCard(): TemplateResult {...}

  render() { return super.render(); }

  static styles = [...super.styles, css`...` ]
}

… and then <browse-page> has:

<public-card-viewer .card=${this.selectedResult}>
</public-card-viewer>

Composition

’s approach is essentially:

<editable-card>
  <card-top-bar></card-top-bar>
  <card-title-bar></card-title-bar>
</editable-card>

Properties go down and events go up. To pass data between siblings, the common ancestor would act as a mediator, e.g., <editable-card> listens for events from <card-top-bar> and then sets properties <card-title-bar>.

Composition can also use slotted children, e.g.,

<base-card>
  <slot name='top-bar'><slot>
  <slot name='title-bar'><slot>
</base-card>

<editable-card>
  <base-card>
    <div slot='top-bar'>...</div>
    <div slot='title-bar'>...</div>
  </base-card>
</editable-card>

… with the caveat that <base-card> cannot make any assumptions about the slotted content because that content is controlled by and can be added/removed at any time.

Reactive Controllers

This would take the form of:

export class MyController implements ReactiveController {
  host: ReactiveControllerHost;

  constructor(host: ReactiveControllerHost) {
    (this.host = host).addController(this);
  }

  // Do something and then call `this.host.requestUpdate()`.
  hostConnected(): void {...}
}

@customElement('my-element')
class MyElement extends LitElement {
  private controller = new MyController(this);
  render() {
    return html`Current value: ${this.controller.value}`;
  }
}

… where a ReactiveController can hook into lifecycle methods such as hostConnected, hostUpdate, hostUpdated, and hostDisconnected.

Mixins

Unlike reactive controllers which implement composition patterns, mixins offer abstract subclasses that can be combined into concrete classes.

This would be something of the form:

declare class CardViewerMixinInterface {
  @property({type: Object}) protected card: Card | null;
  updated(changedProperties: Map<string, any>): void;
  protected updateCarouselCursor(direction: CardsCarouselUpdateCursorDirection): void;
  protected closeDialog(): void;
  protected renderCard(): TemplateResult;
  render(): TemplateResult;
  static styles: CSSResult[];
}

const CardViewerMixin = <T extends LitElementConstructor>(superClass: T) => {
  class CardViewerMixinClass extends superClass implements CardViewerMixinInterface {...};
  return CardViewerMixinClass as Constructor<CardViewerMixinInterface> & T;
}

declare class PublicCardViewerMixinInterface {
  @property({type: Object}) protected card: PublicCardResult;
  protected renderCard(): TemplateResult;
  static styles: CSSResult[];
}

const PublicCardViewerMixin = <T extends LitElementConstructor>(superClass: T) => {
  class PublicCardViewerMixinClass extends superClass implements PublicCardViewerMixinInterface {...};
  return PublicCardViewerMixinClass as Constructor<PublicCardViewerMixinInterface> & T;
}

@customElement('public-card-viewer')
class PublicCardViewer extends PublicCardViewerMixin(CardViewerMixin(LitElement)) {};

This seems overly complicated. PublicCardViewer needs to be applied to a CardViewer instance and this superclass is known at class definition time; the mixin adds more code unnecessarily. What about CardViewerMixin? Its superclass, LitElement, is also known at class definition. Mixins seem overly complicated for my use case. Plain inheritance it is unless there’s a reason to do otherwise.

A good mixin could be ToolbarA11yMixin that implements ARIA: toolbar role - Accessibility | MDN . This mixin can be used by the app bar (e.g., home, browse, wiki, account, logout) and by the card’s action bar (e.g., move to trash, previous, next, save).

References

  1. Component composition – Lit. lit.dev . Accessed May 3, 2024.
  2. Working with Shadow DOM – Lit. lit.dev . Accessed May 3, 2024.
  3. Reactive Controllers – Lit. lit.dev . Accessed May 3, 2024.
  4. Mixins – Lit. lit.dev . Accessed May 3, 2024.