Wait for customElement children to render to position them imperatively

Viewed 689

I have a component MyWrapper which consists of flowing text on the left and a margin on the right. In the margin, there are MarginElement components that should be vertically aligned with <mark> tags in the text.

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

@customElement('my-wrapper')
export class MyWrapper extends LitElement {
  
  static styles = css`:host{display:flex}`

  render() {
    return html`
        <p>
          <mark id="mark1">Lorem</mark> ipsum dolor sit amet, consectetur adipiscing elit, sed
          do eiusmod tempor incididunt ut labore et dolore magna aliqua.
          Ut enim ad minim veniam, quis nostrud exercitation ullamco
          laboris nisi ut aliquip ex ea commodo consequat. Duis aute
          irure dolor in reprehenderit in voluptate velit esse <mark id="mark2">cillium</mark>.
        </p>
        <div class="margin" style="width:50%">
           <margin-element id="m1" related-element-id="mark1">Some note</margin-element>
           <margin-element id="m2" related-element-id="mark2">Some other note</margin-element>
        </div>`;
  }
  
  updated(){this.positionMargin()}
    
  positionMargin() {
    const elements = this.shadowRoot.querySelectorAll('margin-element')
    elements.forEach(el => {
      const relatedElement = this.shadowRoot.querySelector(`#${el.relatedElementId}`)
      el.style.top = relatedElement.getBoundingClientRect().top + 'px'
    })
  }
}

@customElement('margin-element')
export class MarginElement extends LitElement {
  static styles = css`:host {position: absolute; border: 1px solid red;}`

  @property({type: String, attribute: 'related-element-id'}) relatedElementId: string

  render() {return html`<slot></slot>`}
}

I would like to run the function positionMargin() once rendering on MyWrapper is completed, but I understand that to call relatedElement.getBoundingClientRect() margin-element must itself be rendered. Looking at the docs updated() seems to be the best place to call positionMargin(). Yet it seems as if the child <margin-element>s are not rendered by then. Consequently, getBoundingClientRect() is unavailable.

How can this be resolved?

2 Answers

Thanks to the help of the Lit team (Thank you!), I was able to figure it out. The solution is to wait for the children to have rendered before resolving the updateComplete promise.

This can be achieved by querying all <margin-element> children and waiting for their respective updateComplete promise to resolve (see also here).

Therefore, the code above works once I added

async getUpdateComplete() {
        await super.getUpdateComplete();
        const marginElements = Array.from(this.shadowRoot.querySelectorAll('margin-element'));
        await Promise.all(marginElements.map(el => el.updateComplete));
        return true;
    }

Here's a lit playground with the complete example.

When you return an array of MarginElement it kind of works because it causes them to fire their own render lifecycles after the current render has updated, but it won't be reliable.

Your main problem is that you want vertical alignment that runs off relative y-position in the DOM, but x-position stacked on the right hand side. The stacking means that <margin-element> either need to use some kind of flow/grid layout or they need to be aware of each other, which gets nasty fast.

Instead I'd add a new <margin-wrapper> element that's responsible for absolutely positioning it's child <margin-element> and replaces <div class="margin">.

Then in your positionMargin function set an array that you pass to it <margin-wrapper .marks=${this.marksWithPositions}>;

Then in <margin-wrapper>'s render you have an array of marks you can absolutely position, and if you get two at the same top you can shift the subsequent ones on.

Something like:

  render() {
    return html`
      <div class="wrapper">
        <div class="content">
          <p>
            <mark id="mark1">Lorem</mark> ipsum dolor sit ... 
            <mark id="mark2">cillium</mark>.
          </p>
        </div>
        <margin-wrapper .marks=${this.marksWithPositions}>
      </div>
     <button @click=${() => this.positionMargin()}>Position!</button>
    `;
  }

  positionMargin() {
    const elements = this.shadowRoot.querySelectorAll('margin-element')
    const mwp = [];

    elements.forEach(el => {
      const markWithPos ={
        top: relatedElement.getBoundingClientRect().top,
        text: e.innerText
    });

    this.marksWithPositions = mwp; // note that this needs to be an @property or @state
  }

Then in <margin-wrapper>:

render() {
    const positioned = [];
    let x = 0;
    for(const m of this.marks) {
        if(m.top > x)
            positioned.push(m);

        else 
            positioned.push({ top: x, m.text });

        x += expectedElementHeight;
    }

    return html`
<div class="margin">
    ${positioned.map(p => html`
    <margin-element style=${styleMap({top: p.top + 'px'})}>${p.text}</margin-element>`)}
</div>`;
}

Alternatively it might be possible to do something with ref, like <mark ${ref(e => ...)}>, to fire an event when each <mark> renders.

Be very careful that <margin-wrapper>'s render doesn't cause a layout change that moves the <mark> elements, as you could get circular race conditions very easily. You probably want explicit widths and heights everywhere, but if not you're also going to need window resize and scroll observers.

Related