Search code examples
javascripthtmlcustom-element

How to properly visually nest HTML Custom Elements


I'm trying to write a simple HTML Custom Element for putting trees on pages, using simple code like:

<html-tree title="root">
  <tree-node title="child1">
    <tree-node title="leaf1"></tree-node>
    <tree-node title="leaf2"></tree-node>
  </tree-node>
  <tree-node title="child2">
    <tree-node title="leaf3"></tree-node>
    <tree-node title="leaf4"></tree-node>
  </tree-node>
</html-tree>

Each element is basically a shadow dom with a single unnamed <slot></slot> for putting children in, so I'd expect a nice, nested structure. Instead, if I assign the standard debug style :host>* { border:1px red solid; } to these custom elements, each element shows up on its own line, with a border around it, rather than showing them as being nested.

How do I preserve the markup-specified nesting in a way that CSS plays nice?

A snippet:

/**
 * Main tree node class
 */
class GenericNode extends HTMLElement {
  constructor() {
    super();
    this._shadow = enrich(this.attachShadow({mode: `open`}));
    this._shadow.addSlot = () => this._shadow.add(create(`slot`));
    if (!this.get) {
      this.get = e => this.getAttribute(e);
    }
    this.setupDOM(this._shadow);
  }
  setupDOM(shadow) {    
    this.setStyle(`:host>* { border:1px red solid; }`)
    if (this.leadIn) this.leadIn(shadow);
    shadow.addSlot();    
    if (this.leadOut) this.leadOut(shadow);
  }
  setStyle(text) {
    if (!this._style) {
      this._style = create(`style`, text);
      this._shadow.add(this._style);
    } else {
      this._style.textContent = text;
    }
  }
}


/**
 * "not the root" element
 */
class Node extends GenericNode {
  constructor() {
    super();
  }
  leadIn(shadow) {
    shadow.add(create(`p`, this.get(`title`)));
  }
}

// register the component
customElements.define(`tree-node`, Node);

/**
 * "the root" element, identical to Node, of course.
 */
class Tree extends Node  {
  constructor() {
    super();
  }
}

// register the component
customElements.define(`html-tree`, Tree);


/**
 * utility functions
 */

function enrich(x) {
  x.add = e => x.appendChild(e);
  x.remove = e => {
    if (e) x.removeChild(e);
    else e.parentNode.removeChild(e);
  };
  x.get = e => x.getAttribute(x);  
  return x;
}

function find(qs) {
  return Array.from(
    document.querySelectorAll(qs).map(e => enrich(e))
  );
}

function create(e,c) {
  let x = enrich(document.createElement(e));
  x.textContent = c;
  return x;
};
<html-tree title="root">
  <tree-node title="child1">
  <tree-node title="leaf1"></tree-node>
    <tree-node title="leaf2"></tree-node>
  </tree-node>
  <tree-node title="child2">
    <tree-node title="leaf3"></tree-node>
    <tree-node title="leaf4"></tree-node>
  </tree-node>
</html-tree>


Solution

  • Turns out the default styling of a shadow dom and its content is "nothing", so to effect real nesting, you need to force display:block or be similarly explicit.

    In the above code, rather than merely setting a border on the :host>*, the :host, and the <slot> also need to be explicitly marked as blocks:

    setupDOM(shadow) {    
      this.setStyle(`
        :host {
          display: block;
          border: 1px red solid;
        }
        :host > slot {
          display: block;
          border: 1px red solid;
          margin-left: 1em;
        }
      `);
      ...
    }