javascriptecmascript-6native-web-component

Client element resizing issue in HTML web component


Lately I am implementing a lightweight vanilla-JS library with HTML web components for in-company usage only.

I have a behavioral issue in JavaScript regarding the resizing of client elements in a parent container.

This is my test-HTML-file to reproduce the behavior in a small testing scenario:

<!DOCTYPE html>
<html>
<head>
  <meta charset="utf-8" />
  <title>Client resize behavior test in different container implementations</title>
  <style>
    * {
      position: relative;
      box-sizing: border-box;
    }
    html, body {
      margin: 0;
      padding: 0;
      width: 100%;
      height: 100%;
    }
    .container {
      height: 400px;
      width: 600px;
      border: 3px solid black;
      background-color: lightgrey;
      overflow: visible;
    }
    .title {
      position: absolute;
    }
    .outer {
      height: 100%;
      width: 100%;
      padding: 20px;
      padding-top: 50px;
    }
    .inner {
      height: 100%;
      width: 100%;
      border: 3px solid blue;
      background-color: lightblue;
    }
    .client {
      position: absolute;
      border: 3px solid red;
      background-color: lightcoral;
      opacity: .5;
      height: 100%;
      width: 100%;
    }
    button {
      margin: 10px;
    }
  </style>
  <script type="module">
    customElements.define("test-container", class extends HTMLElement {
      constructor() {
        super();
        this.attachShadow({ mode: "open" }).innerHTML = `
        <style>
          * {
            position: relative;
            box-sizing: border-box;
          }
          :host {
            contain: content;
            display: block;
          }
          .shadow-outer {
            height: 100%;
            width: 100%;
            padding: 20px;
            padding-top: 50px;
          }
          .shadow-inner {
            height: 100%;
            width: 100%;
            border: 3px solid blue;
            background-color: lightblue;
          }
        </style>
        <div style="position:absolute;">State-of-the-art HTML web component container with nested DIVS in the shadow-DOM</div>
        <div class="shadow-outer">
          <div class="shadow-inner">
            <slot>
            </slot>
          </div>
        </div>
      `;
      }
    });
    const setClientSizeToParentClientSize = (client, button) => {
      const parent = client.parentElement;
      client.style.position = "absolute";
      client.style.height = `${parent.clientHeight}px`;
      client.style.width = `${parent.clientWidth}px`;
      client.innerHTML += " resized";
      button.disabled = true;
    };
    document.getElementById("set-client1").addEventListener("click", function () {
      setClientSizeToParentClientSize(document.getElementById("client1"), this);
    });
    document.getElementById("set-client2").addEventListener("click", function () {
      setClientSizeToParentClientSize(document.getElementById("client2"), this);
    });
  </script>
</head>
<body>
  <div>
    <div class="container" id="container1">
      <div style="position:absolute;">Plain old light-DOM container with nested DIVs in the light-DOM</div>
      <div class="outer">
        <div class="inner">
          <div class="client" id="client1">Client 1</div>
        </div>
      </div>
    </div>
    <button id="set-client1">Set client 1 size in JavaScript</button>
  </div>
  <div>
    <test-container id="container2" class="container">
      <div class="client" id="client2">Client 2</div>
    </test-container>
    <button id="set-client2">Set client 2 size in JavaScript</button>
  </div>
</body>
</html>

I also created a corresponding JS fiddle.

The container contains two nested DIV elements to create a kind of hard-coded margin between the container's outer boundaries and its inner (client) boundaries.

When using JavaScript to resize the client (child) elements by pressing the resize buttons below the containers, the HTML web component implementation behaves differently from the classic (light-DOM only) implementation.

I think that it has to do with the parent element determined by JavaScript. For the classic implementation, the client's parent will be the inner DIV. But for the HTML web component method, it seems to be the web component itself...

What can I do in JavaScript to let my HTML web component's slotted child elements be (re)sized using JavaScript regarding their shadow-DOM parent in the web component instead of the light-dom parent (being the web component itself)?

Edit:

I suppose I need to clarify the context of my issue a little.

The clients in my container will be draggable (using a drag handle element, something like a title bar) and resizable (using a resize handle, like a triangle in the lower right corner).

Dragging and resizing should be optionally bound to the container's client region (= the client region of the inner DIV). If the "bound"-option is true, the clients are not allowed to cross the container's (inner) boundaries. For this, the mousemove event handlers of both the dragging and resizing behavior will need to perform calculations on the client's boundary with respect to the container's inner client region.

All this dragging and resizing logic is already in place and functioning for classic light-DOM only solutions, but when implementing this logic for client elements in a HTML web component container implementation, the event handling does not recognize the shadow-DOM's inner DIV container as the client's parent for boundary checking; it uses the entire container's client region instead.

I tried to isolate and simplify this technical issue as much as possible in my example.

The client elements in my example are initially already maximized correctly to 100% height and 100% width of the container's client region (using assigned CSS classes).

The buttons in my test example simply add some overriding inline CSS styling with absolute values, which should result in visually the same "maximized" client size.

This logic seems to work fine for the plain old light-DOM solution, but not for the HTML web component's shadow-DOM solution. In the latter case, the JavaScript resizing logic will not assign the web component's inner DIV's clientwidth and -height dimensions, but the entire HTML web component's clientwidth and -height dimensions, which is too large, causing an obvious overflow.

So I need to correct the JavaScript logic in the button event handler in such a way, that it will cause the client in the new HTML web component container implementation to resize correctly: setting the inline CSS absolute values should NOT cause any visual size change!

Implementation and styling of the container may vary dynamically, so the JavaScript solution should not depend on a container's specific visual and/or functional design.

Edit 2:

For even more clarity, I want to include a code sample here that mimics my actual application more accurately.

<!DOCTYPE html>
<html>
<head>
  <meta charset="utf-8" />
  <title>Draggable and resizable client in a custom container element</title>
  <style>
    * {
      position: relative;
      box-sizing: border-box;
    }
    html, body {
      margin: 0;
      padding: 0;
      width: 100%;
      height: 100%;
    }
    .container {
      height: 80%;
      width: 80%;
      border: 3px solid black;
      background-color: lightgrey;
      overflow: visible;
    }
    .outer {
      height: 100%;
      width: 100%;
      padding: 20px;
      padding-top: 50px;
    }
    .inner {
      height: 100%;
      width: 100%;
      border: 3px solid blue;
      background-color: lightblue;
    }
    .client {
      position: absolute;
      border: 3px solid red;
      background-color: lightcoral;
      opacity: .5;
      height: 30%;
      width: 30%;
      min-height: 2rem;
      min-width: 4rem;
    }
    .title {
      background-color: firebrick;
      color: lightyellow;
      cursor: move;
    }
    button {
      margin: 10px;
    }
  </style>
  <script type="module">
    customElements.define("resize-handle", class extends HTMLElement {
      constructor() {
        super();

        this.attachShadow({ mode: "open" }).innerHTML = `
          <style>
            :host {
              display: block;
              contain: content;
              position: absolute !important;
              right: 0 !important;
              bottom: 0 !important;
              top: unset !important;
              left: unset !important;
              width: 0;
              height: 0;
              border: 0;
              border-left: 1rem solid transparent;
              border-bottom: 1rem solid rgba(255, 255, 255, .2);
              cursor: nw-resize;
              z-index: 1;
            }
            :host(.move) {
              top: 0 !important;
              left: 0 !important;
              width: unset !important;
              height: unset !important;
              border: 0;
              background: rgba(255, 255, 255, .2) !important;
            }
          </style>
        `;

        this.mouseDownEventListener = (event) => this.handleMouseDown(event);
        this.mouseUpEventListener = (event) => this.handleMouseUp(event);

        this.addEventListener("mousedown", this.mouseDownEventListener);
      }

      handleMouseDown(event) {
        if (event.buttons !== 0x1 || event.shiftKey || event.ctrlKey || event.altKey) {
          return;
        }

        this.classList.add("move");

        document.addEventListener("mouseup", this.mouseUpEventListener);
      }

      handleMouseUp(event) {
        if ((event.buttons & 0x1) === 0x1) {
          return;
        }

        this.classList.remove("move");

        document.removeEventListener("mouseup", this.mouseUpEventListener);
      }
    });

    customElements.define("test-container", class extends HTMLElement {
      constructor() {
        super();

        this.attachShadow({ mode: "open" }).innerHTML = `
          <style>
            * {
              position: relative;
              box-sizing: border-box;
            }
            :host {
              contain: content;
              display: block;
            }
            .shadow-outer {
              height: 100%;
              width: 100%;
              padding: 20px;
              padding-top: 50px;
            }
            .shadow-inner {
              height: 100%;
              width: 100%;
              border: 3px solid blue;
              background-color: lightblue;
            }
          </style>
          <div style="position:absolute;">Container (&lt;test-container&gt; HTML web component)</div>
          <div class="shadow-outer">
            <div class="shadow-inner">
              <slot>
              </slot>
            </div>
          </div>
        `;

        this.innerDiv = this.shadowRoot.querySelector(".shadow-inner");
      }

      get containerClientHeight() {
        return this.innerDiv.clientHeight;
      }

      get containerClientWidth() {
        return this.innerDiv.clientWidth;
      }
    });

    class Drag {
      constructor(element, handle, options) {
        this.element = element;
        this.handle = handle;
        this.options = {
          bounds: options && options.bounds != null ? options.bounds : true
        };

        this.x = 0;
        this.y = 0;
        this.left = 0;
        this.top = 0;
        this.dragging = false;

        this.mouseDownEventListener = (event) => this.handleMouseDown(event);
        this.mouseMoveEventListener = (event) => this.handleMouseMove(event);
        this.mouseUpEventListener = (event) => this.handleMouseUp(event);

        this.handle.addEventListener("mousedown", this.mouseDownEventListener);
      }

      handleMouseDown(event) {
        if (this.dragging) {
          return;
        }

        if (event.buttons !== 0x1 || event.shiftKey || event.ctrlKey || event.altKey) {
          return;
        }

        event.preventDefault();

        this.x = event.clientX;
        this.y = event.clientY;
        this.left = this.element.offsetLeft;
        this.top = this.element.offsetTop;
        this.dragging = true;

        document.addEventListener("mousemove", this.mouseMoveEventListener);
        document.addEventListener("mouseup", this.mouseUpEventListener);
      }

      handleMouseMove(event) {
        if (!this.dragging) {
          document.removeEventListener("mousemove", this.mouseMoveEventListener);
          document.removeEventListener("mouseup", this.mouseUpEventListener);
          return;
        }

        let left = this.left + event.clientX - this.x;
        let top = this.top + event.clientY - this.y;

        if (this.options.bounds) {
          const parent = this.element.parentElement || document.body;

          let clientWidth = parent.containerClientWidth !== undefined ? parent.containerClientWidth : parent.clientWidth;
          let clientHeight = parent.containerClientHeight !== undefined ? parent.containerClientHeight : parent.clientHeight;

          // HACK - NOT FOR PRODUCTION
          if (document.querySelector("#oldbehavior").checked) {
            clientWidth = parent.clientWidth;
            clientHeight = parent.clientHeight;
          }

          if (left > clientWidth - this.element.offsetWidth) {
            left = clientWidth - this.element.offsetWidth;
          }

          if (left <= 0) {
            left = 0;
          }

          if (top > clientHeight - this.element.offsetHeight) {
            top = clientHeight - this.element.offsetHeight;
          }

          if (top <= 0) {
            top = 0;
          }
        }

        this.element.style.left = `${left}px`;
        this.element.style.top = `${top}px`;
      }

      handleMouseUp(event) {
        if ((event.buttons & 0x1) === 0x1) {
          return;
        }

        document.removeEventListener("mousemove", this.mouseMoveEventListener);
        document.removeEventListener("mouseup", this.mouseUpEventListener);

        this.dragging = false;
      }
    }

    class Resize {
      constructor(element, handle, options) {
        this.element = element;
        this.handle = handle;
        this.options = {
          bounds: options && options.bounds != null ? options.bounds : true
        };

        this.x = 0;
        this.y = 0;
        this.width = 0;
        this.height = 0;
        this.resizing = false;

        this.mouseDownEventListener = (event) => this.handleMouseDown(event);
        this.mouseMoveEventListener = (event) => this.handleMouseMove(event);
        this.mouseUpEventListener = (event) => this.handleMouseUp(event);

        this.handle.addEventListener("mousedown", this.mouseDownEventListener);
      }

      handleMouseDown(event) {
        if (this.resizing) {
          return;
        }

        if (event.buttons !== 0x1 || event.shiftKey || event.ctrlKey || event.altKey) {
          return;
        }

        event.preventDefault();

        const clientRect = this.element.getBoundingClientRect();
        this.x = event.clientX;
        this.y = event.clientY;
        this.width = clientRect.width;
        this.height = clientRect.height;
        this.resizing = true;

        document.addEventListener("mousemove", this.mouseMoveEventListener);
        document.addEventListener("mouseup", this.mouseUpEventListener);
      }

      handleMouseMove(event) {
        if (!this.resizing) {
          document.removeEventListener("mousemove", this.mouseMoveEventListener);
          document.removeEventListener("mouseup", this.mouseUpEventListener);
          return;
        }

        let width = this.width + event.clientX - this.x;
        let height = this.height + event.clientY - this.y;

        if (this.options.bounds) {
          const parent = this.element.parentElement || document.body;

          let clientWidth = parent.containerClientWidth !== undefined ? parent.containerClientWidth : parent.clientWidth;
          let clientHeight = parent.containerClientHeight !== undefined ? parent.containerClientHeight : parent.clientHeight;

          // HACK - NOT FOR PRODUCTION
          if (document.querySelector("#oldbehavior").checked) {
            clientWidth = parent.clientWidth;
            clientHeight = parent.clientHeight;
          }

          if (width > clientWidth - this.element.offsetLeft) {
            width = clientWidth - this.element.offsetLeft;
          }

          if (height > clientHeight - this.element.offsetTop) {
            height = clientHeight - this.element.offsetTop;
          }
        }

        this.element.style.width = `${width}px`;
        this.element.style.height = `${height}px`;
      }

      handleMouseUp(event) {
        if ((event.buttons & 0x1) === 0x1) {
          return;
        }

        document.removeEventListener("mousemove", this.mouseMoveEventListener);
        document.removeEventListener("mouseup", this.mouseUpEventListener);

        this.resizing = false;
      }
    }

    const client = document.querySelector(".client");
    const title = document.querySelector(".title");
    const handle = document.querySelector("resize-handle");

    const bounds = document.getElementById("bounds");
    const oldbehavior = document.getElementById("oldbehavior");

    const drag = new Drag(client, title, { bounds: bounds.checked });
    const resize = new Resize(client, handle, { bounds: bounds.checked });

    document.getElementById("bounds").addEventListener("click", function () {
      drag.options.bounds = this.checked;
      resize.options.bounds = this.checked;
      oldbehavior.disabled = !this.checked;
    });
  </script>
</head>
<body>
  <div>
    <input type="checkbox" id="bounds" checked />
    <label for="bounds" title="Deny the client to cross boundaries.">Bounds checking</label>
  </div>
  <div>
    <input type="checkbox" id="oldbehavior" />
    <label for="checkbox" title="The old behavior does not get the correct client region of the container, thus allowing slight overflow.">Old behavior</label>
  </div>
  <test-container class="container">
    <div class="client">
      <div class="title">
        <span>Client</span>
      </div>
      <resize-handle></resize-handle>
    </div>
  </test-container>
</body>
</html>

The checkbox "Bounds checking" will allow to disable/enable boundary checking altogether.

The checkbox "Old behavior" toggles the boundary checking behavior. When checked, it falls back to the original issue. When unchecked, it uses the solution as provided in my own answer.

I am not yet fully satisfied, so I will keep searching for other solutions for a short while. Please let me know if there is a better way for determining/calculating the container's effective client region within JavaScript. Thanks in advance.


Solution

  • I found an answer, which @Supersharp also pointed out in a comment. It is actually pretty straightforward.

    The HTML web component container implementation should just get some read-only properties (containerClientHeight and containerClientWidth, for example) which return its shadow-DOM's inner DIV's client dimensions. These properties can be used in the button click event handler.

    This is my final working code:

    <!DOCTYPE html>
    <html>
    <head>
      <meta charset="utf-8" />
      <title>Client resize behavior test in different container implementations</title>
      <style>
        * {
          position: relative;
          box-sizing: border-box;
        }
        html, body {
          margin: 0;
          padding: 0;
          width: 100%;
          height: 100%;
        }
        .container {
          height: 400px;
          width: 600px;
          border: 3px solid black;
          background-color: lightgrey;
          overflow: visible;
        }
        .title {
          position: absolute;
        }
        .outer {
          height: 100%;
          width: 100%;
          padding: 20px;
          padding-top: 50px;
        }
        .inner {
          height: 100%;
          width: 100%;
          border: 3px solid blue;
          background-color: lightblue;
        }
        .client {
          position: absolute;
          border: 3px solid red;
          background-color: lightcoral;
          opacity: .5;
          height: 100%;
          width: 100%;
        }
        button {
          margin: 10px;
        }
      </style>
      <script type="module">
        customElements.define("test-container", class extends HTMLElement {
          constructor() {
            super();
            this.attachShadow({ mode: "open" }).innerHTML = `
              <style>
                * {
                  position: relative;
                  box-sizing: border-box;
                }
                :host {
                  contain: content;
                  display: block;
                }
                .shadow-outer {
                  height: 100%;
                  width: 100%;
                  padding: 20px;
                  padding-top: 50px;
                }
                .shadow-inner {
                  height: 100%;
                  width: 100%;
                  border: 3px solid blue;
                  background-color: lightblue;
                }
              </style>
              <div style="position:absolute;">State-of-the-art HTML web component container with nested DIVS in the shadow-DOM</div>
              <div class="shadow-outer">
                <div class="shadow-inner">
                  <slot>
                  </slot>
                </div>
              </div>
            `;
            this.innerDiv = this.shadowRoot.querySelector(".shadow-inner");
          }
          get containerClientHeight() {
            return this.innerDiv.clientHeight;
          }
          get containerClientWidth() {
            return this.innerDiv.clientWidth;
          }
        });
        const setClientSizeToParentClientSize = (client, button) => {
          const parent = client.parentElement;
          client.style.position = "absolute";
          client.style.height = `${parent.containerClientHeight !== undefined ? parent.containerClientHeight : parent.clientHeight}px`;
          client.style.width = `${parent.containerClientWidth !== undefined ? parent.containerClientWidth : parent.clientWidth}px`;
          client.innerHTML += " resized";
          button.disabled = true;
        };
        document.getElementById("set-client1").addEventListener("click", function () {
          setClientSizeToParentClientSize(document.getElementById("client1"), this);
        });
        document.getElementById("set-client2").addEventListener("click", function () {
          setClientSizeToParentClientSize(document.getElementById("client2"), this);
        });
      </script>
    </head>
    <body>
      <div>
        <div class="container" id="container1">
          <div style="position:absolute;">Plain old light-DOM container with nested DIVs in the light-DOM</div>
          <div class="outer">
            <div class="inner">
              <div class="client" id="client1">Client 1</div>
            </div>
          </div>
        </div>
        <button id="set-client1">Set client 1 size in JavaScript</button>
      </div>
      <div>
        <test-container id="container2" class="container">
          <div class="client" id="client2">Client 2</div>
        </test-container>
        <button id="set-client2">Set client 2 size in JavaScript</button>
      </div>
    </body>
    </html>
    

    Both the buttons now add inline CSS styling for absolute dimensions to their targeted clients in such a way, that they match the actual container's client regions. Both implementations will not cause an overflow of the client anymore. (There will be no visual change when the buttons are pressed.)