nojhan
nojhan

Reputation: 1332

How to propagate property value down to a child of a LitElement

I want to compose a LitElement widget from two other widgets. The basic idea would be that one "selector" widget selects something, send an event to its parent "mediator" one, which in turns would update the related property in a "viewer" child widget (event up, property down). This follows the "mediator pattern" example of the documentation about composition, only that I need to deal with fully separated classes.

Below is a minimum working example, which builds and run in the LitElement TypeScript starter template project, with all dependencies force-updated with ncu --upgradeAll.

Detailed description

The desired behavior would be that the Viewer widget does render the item["name"] when the user selects something in the dropdown list.

So far, I managed to send an event up from the Selector to the Mediator, and to update the item_id attribute within the Viewer. However, this does not seem to trigger an update of the item_id property of the Viewer.

The approach used is to override updated in the Mediator (l.36), loop through its children and do a child.setAttribute, which is probably highly inelegant. There is surely another —cleaner— way, but I failed to clearly understand the update sequence of Lit.

Example

index.html

<!DOCTYPE html>

<html>
  <head>
    <meta charset="utf-8" />
    <title>Demo</title>
    <script src="../node_modules/@webcomponents/webcomponentsjs/webcomponents-loader.js"></script>
    <script src="../node_modules/lit/polyfill-support.js"></script>

    <script type="module" src="../mediator.js"></script>
    <script type="module" src="../selector.js"></script>
    <script type="module" src="../viewer.js"></script>
    <style>
    </style>
  </head>
  <body>
    <h1>Demo</h1>
    <my-mediator>
        <my-selector slot="selector"></my-selector>
        <my-viewer   slot="viewer" />
    </my-mediator>
  </body>
</html>

mediator.ts

import {LitElement, html, css, PropertyValues} from 'lit';
// import {customElement, state} from 'lit/decorators.js';
import {customElement, property} from 'lit/decorators.js';

@customElement('my-mediator')
export class Mediator extends LitElement {

    // @state()
    @property({type: Number, reflect: true})
    item_id: number = Number.NaN;

    static override styles = css` `;

    override render() {
        console.log("[Mediator] rendering");
        return html`<h2>Mediator:</h2>
              <div @selected=${this.onSelected}>
                  <slot name="selector" />
              </div>
              <div>
                  <slot name="viewer" @slotchange=${this.onSlotChange} />
              </div>`;
    }
    
    private onSelected(e : CustomEvent) {
        console.log("[Mediator] Received selected from selector: ",e.detail.id);
        this.item_id = e.detail.id;
        this.requestUpdate();
    }
    
    private onSlotChange() {
        console.log("[Mediator] viewer's slot changed");
        this.requestUpdate();
    }

    override updated(changedProperties:PropertyValues<any>): void {
        super.updated(changedProperties);
        // It is useless to set the Selector's item_id attribute,
        // as it is sent to the Mediator through an event.
        for(const child of Array.from(this.children)) {
            if(child.slot == "viewer" && !Number.isNaN(this.item_id)) {
                console.log("[Mediator] Set child viewer widget's selection to: ", this.item_id);
                // FIXME: the item_id attribute is set in the Viewer,
                // but it does not trigger an update in the Viewer.
                child.setAttribute("item_id", `${this.item_id}`);
            }
        }
    }


}

declare global {
  interface HTMLElementTagNameMap {
    'my-mediator': Mediator;
  }
}

viewer.ts

import {LitElement, html,css} from 'lit';
import {customElement, property} from 'lit/decorators.js';

@customElement('my-viewer')
export class Viewer extends LitElement {

    @property({type: Number, reflect: true})
    item_id = Number.NaN;

    private item: any = {};

    static override styles = css` `;

    override connectedCallback(): void {
        console.log("[Viewer] Callback with item_id",this.item_id);
        super.connectedCallback();
        if(!Number.isNaN(this.item_id)) {
            const items = [
                {"name":"item 1","id":1},
                {"name":"item 2","id":2},
                {"name":"item 3","id":3}
            ];
            this.item = items[this.item_id];
            this.requestUpdate();
        } else {
            console.log("[Viewer] Invalid item_id: ",this.item_id,", I will not go further.");
        }
    }

    override render() {
        console.log("[Viewer] rendering");
        return html`<h2>Viewer:</h2>
            <p>Selected item: ${this.item["name"]}</p>`;
    }

}

declare global {
  interface HTMLElementTagNameMap {
    'my-viewer': Viewer;
  }
}

selector.ts

import {LitElement, html,css} from 'lit';
import {customElement, property} from 'lit/decorators.js';

@customElement('my-selector')
export class Selector extends LitElement {

    @property({type: Number, reflect: true})
    item_id = Number.NaN;

    private items: Array<any> = [];

    static override styles = css` `;

    override connectedCallback(): void {
        console.log("[Selector] Callback");
        super.connectedCallback();
        this.items = [
            {"name":"item 0","id":0},
            {"name":"item 2","id":2},
            {"name":"item 3","id":3}
        ];
        this.item_id = this.items[0].id;
        this.requestUpdate();
    }

    override render() {
        console.log("[Selector] Rendering");
        // FIXME the "selected" attribute does not appear.
        return html`<h2>Selector:</h2>
            <select @change=${this.onSelection}>
                ${this.items.map((item) => html`
                    <option
                        value=${item.id}
                        ${this.item_id == item.id ? "selected" : ""}
                    >${item.name}</option>
                `)}
            </select>`;
    }

    private onSelection(e : Event) {
        const id: number = Number((e.target as HTMLInputElement).value);
        if(!Number.isNaN(id)) {
            this.item_id = id;
            console.log("[Selector] User selected item: ",this.item_id);
            const options = {
                detail: {id},
                bubbles: true,
                composed: true
            };
            this.dispatchEvent(new CustomEvent('selected',options));
        } else {
            console.log("[Selector] User selected item, but item_id is",this.item_id);
        }
    }
}

declare global {
  interface HTMLElementTagNameMap {
    'my-selector': Selector;
  }
}

Related questions

Upvotes: 0

Views: 1228

Answers (1)

Christian
Christian

Reputation: 4094

You are setting the item only in the connectedCallback. Better you get the item dynamically via getter.

Regarding your question of setting an attribute of a slotted element, there is in my opinion no other way than programmatically.

<script type="module">
import {
  LitElement,
  html
} from "https://unpkg.com/lit-element/lit-element.js?module";

class MyMediator extends LitElement {
  static get properties() {
    return {
      item_id: {type: Number},
    };
  }
 
  onSelected(e) {
    this.item_id = e.detail.id;
  }
 
  render() {
    return html`<h3>Mediator:</h3>
              <div @selected=${this.onSelected}>
                  <slot name="selector" />
              </div>
              <div>
                  <slot name="viewer" />
              </div>`;
  }
  
  updated(changedProperties) {
    super.updated(changedProperties);
    for(const child of Array.from(this.children)) {
      if(child.slot === "viewer" && !Number.isNaN(this.item_id)) {
        child.setAttribute("item_id", `${this.item_id}`);
      }
    }
  }
}

class MyViewer extends LitElement {
  
  static get properties() {
    return {
      item_id: {
        type: Number,
      }      
    };
  }
  
  constructor() {
    super();
    this.item = {};
  }
 
  render() {
    return html`<h3>Viewer:</h3>
            <p>Selected item: ${this.getItem()["name"]}</p>`;
  }
    
  getItem() {
      const items = [
        {"name":"item 1","id":1},
        {"name":"item 2","id":2},
        {"name":"item 3","id":3}
      ];
      return items.find(item => item.id === (this.item_id || 1));
  }
}

class MySelector extends LitElement {
  
  static get properties() {
    return {
      item_id: {type: Number},
    };
  }
  
  constructor() {
    super();
    this.items = [];
  }
 
  connectedCallback() {
    super.connectedCallback();
    this.items = [
      {"name":"item 0","id":0},
      {"name":"item 2","id":2},
      {"name":"item 3","id":3}
    ];
    this.item_id = this.items[0].id;
  }
 
  render() {
    return html`<h3>Selector:</h3>
            <select @change=${this.onSelection}>
                ${this.items.map((item) => html`
                    <option
                        value=${item.id}
                        ${this.item_id === item.id ? "selected" : ""}
                    >${item.name}</option>
                `)}
            </select>`;
  }
    
  onSelection(e) {
    const id = Number(e.target.value);
    if(!Number.isNaN(id)) {
      this.item_id = id;
      const options = {
        detail: {id},
        bubbles: true,
        composed: true
      };
      this.dispatchEvent(new CustomEvent('selected',options));
    }
  }
}

customElements.define("my-mediator", MyMediator);
customElements.define("my-viewer", MyViewer);
customElements.define("my-selector", MySelector);
</script>
<my-mediator>
  <my-selector slot="selector"></my-selector>
  <my-viewer slot="viewer" />
</my-mediator>

Upvotes: 1

Related Questions