WebDev Bites: Splitter Component

two panel view with splitter bar

Splitter Component Demo

click in panel to enlarge

Using default bar position

  import std;
      int main() {
        std::cout << "\n  Hello Splitter\n\n";
      }
        

Output here

Using left-width="24rem" bar position

  import std;
      int main() {
        std::cout << "\n  Hello Splitter\n\n";
      }
        

Output here

1.0 - W3C Web Components

Splitter is a W3C Web Component. Web components use the technologies: The splitter component does not explicitly use a template. Instead it uses: shadow.innerHTML = '
    markup here
'
since that markup block is used only once. Templates are typically used to capture small snips of markup that are applied multiple times.
W3C Components need to be registered with the browser instance to become active elements of a page's markup. That is done with: Elements.define('splitter-container', SplitterContainer) The first argument defines the tag name, and the second is the name of the component's constructor.

2.-

Method / Feature Description When Called Notes
constructor() Initial setup of the element. Typically attach shadow DOM, initialize internal state. Immediately upon creation (before insertion into document). Must call super() first. Avoid DOM-dependent operations here.
connectedCallback() Invoked when the element is inserted into the document’s DOM. After the element becomes part of the live document tree. Start observers, fetch data, perform rendering that depends on being in DOM.
disconnectedCallback() Cleanup when the element is removed from the DOM. When the element is detached from the document. Remove event listeners, cancel timers, etc.
attributeChangedCallback(name, oldValue, newValue) Responds to changes in specified attributes. Each time one of the observed attributes changes. Requires declaring static get observedAttributes(); compare old vs new to avoid redundant work.
static get observedAttributes() Returns the array of attribute names to monitor for changes. Read by the browser to know which attribute mutations trigger attributeChangedCallback. Must be static. Example: ['foo', 'bar'].

2.0 - Splitter Source Code

    
Fig 1. - Splitter Component Code
//-----------------------------------------------
// usage example
//-----------------------------------------------
// <!-- usage: defaults to 50/50 split -->
// <splitter-container style="height:300px;">
//   <div slot="first">.left content.</div>
//   <div slot="second">.right content.</div>
// </splitter-container>

// <!-- usage: left pane starts at 200px wide -->
// <splitter-container left-width="200px" style="height:300px;">
//   <div slot="first">.left content.</div>
//   <div slot="second">.right content.</div>
// </splitter-container>
//-----------------------------------------------

// SplitterComponent.js

class SplitterContainer extends HTMLElement {
  static get observedAttributes() {
    return ['left-width'];
  }

  constructor() {
    super();
    this._step    = 100;   // click-resize step in px
    this._minPane = 30;    // minimum pane width in px

    const shadow = this.attachShadow({ mode: 'open' });
    shadow.innerHTML = `
      <style>
        :host { display: block; width:100%; }
        .container {
          display: flex;
          width:100%; height:100%; overflow:hidden;
          border:3px solid var(--dark,#333);
        }
        .pane {
          background:#eee; overflow:auto; user-select:none;
        }
        .pane.first {
          width: var(--left-width,50%);
          flex: 0 0 auto;
        }
        .pane.second {
          flex: 1 1 auto;
        }
        .splitter {
          flex: 0 0 10px;
          margin: 0 0.25rem;
          background: var(--dark,#333);
          cursor: col-resize;
          user-select: none;
        }
      </style>
      <div class="container">
        <div class="pane first"><slot name="first"></slot></div>
        <div class="splitter"></div>
        <div class="pane second"><slot name="second"></slot></div>
      </div>
    `;
  }

  connectedCallback() {
    this._applyLeftWidth();
    this._initDrag();
    this._initClickResize();
  }

  attributeChangedCallback(name, oldVal, newVal) {
    if (name === 'left-width') this._applyLeftWidth();
  }

  _applyLeftWidth() {
    const raw = this.getAttribute('left-width');
    if (raw != null) {
      const cleaned = raw.trim().replace(/;$/, '');
      this.style.setProperty('--left-width', cleaned);
    } else {
      this.style.removeProperty('--left-width');
    }
  }

  _initDrag() {
    const splitter  = this.shadowRoot.querySelector('.splitter');
    const firstPane = this.shadowRoot.querySelector('.pane.first');

    splitter.addEventListener('pointerdown', e => {
      e.preventDefault();
      const startX        = e.clientX;
      const startWidth    = firstPane.getBoundingClientRect().width;
      const hostWidth     = this.getBoundingClientRect().width;
      const splitterWidth = splitter.getBoundingClientRect().width;
      const minW = this._minPane;
      const maxW = hostWidth - splitterWidth - this._minPane;

      const onMove = ev => {
        let newW = startWidth + (ev.clientX - startX);
        newW = Math.min(Math.max(newW, minW), maxW);
        firstPane.style.width = `${newW}px`;
      };
      const onUp = () => {
        document.removeEventListener('pointermove', onMove);
        document.removeEventListener('pointerup',   onUp);
      };

      document.addEventListener('pointermove', onMove);
      document.addEventListener('pointerup',   onUp);
    });
  }

  _initClickResize() {
    const firstPane  = this.shadowRoot.querySelector('.pane.first');
    const secondPane = this.shadowRoot.querySelector('.pane.second');
    const splitter   = this.shadowRoot.querySelector('.splitter');

    const clampWidth = w => {
      const hostWidth     = this.getBoundingClientRect().width;
      const splitterWidth = splitter.getBoundingClientRect().width;
      const minW = this._minPane;
      const maxW = hostWidth - splitterWidth - this._minPane;
      return Math.min(Math.max(w, minW), maxW);
    };

    // expand on left-pane click
    firstPane.addEventListener('click', () => {
      const curW = firstPane.getBoundingClientRect().width;
      firstPane.style.width = `${clampWidth(curW + this._step)}px`;
    });

    // shrink on right-pane click
    secondPane.addEventListener('click', () => {
      const curW = firstPane.getBoundingClientRect().width;
      firstPane.style.width = `${clampWidth(curW - this._step)}px`;
    });
  }
}

customElements.define('splitter-container', SplitterContainer);
Splitter web component code is shown in Figure 1.