How to make a resizable panel control with Web Components
Tags: webdev, webcomponents, customelements, vanillajs
Published: 2020-10-19 04:26:58

In this post we're going to build a resizable panel control called `wc-split-panel` with vanilla web components and no build magic.  If you haven't used web components much this will admittedly be dense with features you've probably never seen, but hopefully this will inspire you to work more with native web and better understand what's going on once frameworks catch up.

The requirements are thus:

- We want to have two panels of arbitrary content
- The panels can be stacked vertically or horizontally
- In the middle will be a place the user can grab and drag to resize the panels.

# Boilerplate

First, I'll lay out some boilerplate:

```js
export class WcSplitPanel extends HTMLElement {
  static observedAttributes = [];
  constructor(){
    super();
    this.bind(this);
  }
  bind(element){
    element.render = element.render.bind(element);
    element.cacheDom = element.cacheDom.bind(element);
    element.attachEvents = element.attachEvents.bind(element);
  }
  connectedCallback(){
    this.render();
    this.cacheDom();
    this.attachEvents();
  }
  render(){
    this.attachShadow({ mode: "open" });
    this.shadowRoot.innerHTML = ``;
  }
  cacheDom(){
    this.dom = {
    };
  }
  attachEvents(){
  
  }
  attributeChangedCallback(name, oldValue, newValue){
    if(oldValue !== newValue){
      this[name] = newValue;
    }
  }
}
```

First let's start with the class declaration.  For consistency it's going to be the TitleCased name of the component `wc-split-panel`.  Why `wc-split-panel`?  Custom elements are required to have prefixes using a hyphen.  While "split-panel" would work if I wanted a single word name for a component, then I'd need a prefix. For my components I use the prefex "wc" for "web compoenent".

Next we have a static property "observedAttributes".  This is part of the custom element spec and is used to tell which attributes you intend to handle since there is a non-zero cost of observing them.  We will be adding some but for now let's leave it blank.  Note that if you see other tutorials you might see it written like this:

```js
static get observedAttributes(){
   return ["foo", "bar"];
}
```
This is because, at least as of this writing class properties are pretty new to class syntax, it used to be that you could only add getters and setters to classes (i.e. methods but not data).  This was revised and so now the property syntax is most appropriate but it's pretty much the same thing.

Next we reach the constructor.  You don't need one, but I find it's useful to do binding here (we'll get to this in a moment).  Constructors should not have parameters for a custom element (since it's usually called by `document.createElement()`) and you should use properties to parameterize things.  Also, if you have a constructor you are required to call `super()`.

Now we bind.  This is technically optional but something I like to do with most classes.  The `this` value that comes with class methods is usually the class but not always.  If you are using things like `setTimeout` or an event listener you might wind up with window.  Binding prevents that by ensuring that `this` always references this class.  Unfortunately this is annoying boilerplate but someday will be easier to fix with decorators.

`connectedCallback` is the main entrypoint to the element and is automatically called when it is inserted into the DOM.  We created 3 methods: `render`, `cacheDom`, and `attachEvents`.  These are lifecycle methods I use for almost all of my custom elements.

`render` is used for creating the DOM.  This can be shadow DOM or light DOM (though shadow is better for encapsulation so I do it by default).  If you provide DOM this may not be needed (declarative shadow DOM is still very new so I don't count on it yet) but you can also use this to ensure the DOM is how you want it.  Per recommendation I use an open shadow DOM so users can extend it if necessary.  `innerHTML` is the easiest way to dump a whole bunch of HTML into the element without a framework and this should only be done once.  We can just be smart about our element and only change things as necessary so we don't need to deal with the performance or over-the-wire costs of a virtual DOM.  If we need to change the element's internal structure, I typically create another method called `rerender` that deals with making the necessary changes but does not completely refresh the DOM.

Next is `cacheDom`.  This is where you get and store all the references to elements you intend to use.  I put these on an internal object called `dom`.  You should not need to make `querySelector` calls outside of this, do it all up-front.

`attachEvents` is where all the event listeners are setup.  There are none yet.  Usually I create methods for all event listeners rather than use inline functions as it becomes easier to maintain.  It's also helpful incase you need to call `removeEventListener` later as the function reference is stable.

Finally, we have the `attributeChangedCallback`.  This is where changes to the HTML will be picked up.  I typically use a guard to prevent same attributes from triggering updates.  This serves two purposes.  The first is performance, especially if the attribute has side-effects, the second is because it prevents infinite loops if you try to reflect a property on an attribute as the property will update the attribute and then try to update the property again.  Though you may want to compare it with the property value rather than the old value to short cut a little faster.  It all depends on how you set things up.

#Setup the DOM

So, back to `render` we want to render the parts we need for our split panel.  We actually don't need much, all we really need are two slots for the panels and the bar that goes between them.  We can just dump this all into `innerHTML` of the shadow root.

```html
  <slot id="slot1" name="1"></slot>
  <div id="median"></div>
  <slot id="slot2" name="2"></slot>
```

I struggled with what to name the slots.  "left/right" wouldn't work when vertical, there is an ordering component to them but neither have priority over the other.  Using "1" and "2" was all I could think of but maybe someone has a better idea.  In any case, we now have the API for our child elements:

```html
<wc-split-panel>
  <div slot="1">Panel 1 Content</div>
  <div slot="2">Panel 2 Content</div>
</wc-split-panel>
```
#Setup the Attributes

We know that we want to change the orientation and so we'll do this at a property. 

[Now if you happened to read the WTAG guidelines this is not advised](https://w3ctag.github.io/webcomponents-design-guidelines/#:~:text=Don%E2%80%99t%20use%20custom%20attributes%20for%20styling).  This is because orientation is purely a display value so really, we want this to be controlled via a custom property.  Unfortunately, this is easier said than done, having a single property change multiple styles is very hard.  So without better tools for doing this I would advise that, for now, you use attributes where more complex styling is necessary. 

We'll call this property `direction` and it will take two values `row` or `column`.  These were chosen as they are similar to those used for flexbox.  We can add this to the `observedAttributes` `static observedAttributes = ["direction"]`.  We can then look at `this.direction` to decide how to render.  However, I'm also going to setup this property with a getter and setter, the reason is because we want to be able to change the orientation programmatically eg `document.querySelector("split-panel).direction = "column";`.  When we do this, we want the attribute to reflect the direction as well and we can use this to use CSS to style each orientation.  Another thing that's not obvious yet but will be is that we also want changing the orientation to reset any positions we might have set.

```js
#direction = "row
set direction(value){
  this.#direction = value;
  this.setAttribute("direction", value);
  this.style.gridTemplateRows = "";
  this.style.gridTemplateColumns = "";
}
get direction(){
  return this.#direction;
}
```
Since we're using the setter we want to keep track of the direction in another property.  While private fields aren't the first thing I tend to reach for these are a really good place to use them since there's a clear public and private element and we'd need to reuse the property name with an underscore or something for the private version.  The default will be "row" and setting it will update the attribute and reset any styles we've added so far.

# Styles

We need to some styles to get the layout, and I feel a grid layout makes the most sense.  We could do with flexbox too but grid lets us position the items from the container rather than the element itself which makes a lot of things easier.  Like the HTML content we can just dump this as a style tag in the `innerHTML`

```css
: host{ display: grid; }
: host([resizing]){ user - select: none; }
: host([resizing][direction = row]){ cursor: col - resize; }
: host([direction = row]) { grid - template - columns: 1fr max - content 1fr; }
: host([direction = row]) #median { inline - size: 0.5rem; grid - column: 2 / 3; }
: host([direction = row]) #median: hover { cursor: col - resize; }
: host([direction = row]) #slot1 { grid - column: 1 / 2; grid - row: 1 / 1; }
: host([direction = row]) #slot2 { grid - column: 3 / 4; grid - row: 1 / 1; }
: host([resizing][direction = col]){ cursor: row - resize; }
: host([direction = column]) { grid - template - rows: 1fr max - content 1fr; }
: host([direction = column]) #median { block - size: 0.5rem; grid - row: 2 / 3; }
: host([direction = column]) #median: hover { cursor: row - resize; }
: host([direction = column]) #slot1 { grid - row: 1 / 2; grid - column: 1 / 1; }
: host([direction = column]) #slot2 { grid - row: 3 / 4; grid - column: 1 / 1; }
#median { background: #ccc; }
:: slotted(*) { overflow: auto; }
```

Let's go over this.  `:host` is how we refer to the current element from within the shadow DOM and we want it to be `display:grid`.  `:host` can also be used with parens to match hosts with other classes/attributes/states, so we use `:host([direction=row])` to add styles when the element is in the "row" state and this is why we chose to reflect the direction property to the direction attribute, had we not done this it would work when set from HTML but not from JS.  We're going to setup 3 grid-lines, the two panels at the start take up equal space `1fr` and the "median" in the middle will be max-content because we want it to take whatever the median's width/height is. For the defaults I'm using logical spacing properties, `inline-size` is the same as `width` and `block-size` is the same as `height` but this notation is more agnostic to orientation so you should use it if available.  We now position slot1 and slot2 on those grid lines and give the median the proper cursor to let the user know they can resize.  The rest is the same except for column orientation.  The last part uses `::slotted` which selects slotted content (the real templated content, not the slot).  We select `*` because we don't know or care what type of element the user slotted but we do want it to overflow otherwise in vertical orientations the text will bleed into the lower panel.

Some might ask if this is a place to use constructable style sheets.  I generally would say "no."  Aside from the API being kinda weird at the moment it won't really buy you anything over inserting CSS and is more for CSS-in-JS paradigms.  Maybe if you had an enormous amount of styles it might parse faster.  If you had a CSS theme stylesheet you wanted to use I'd just use a `link` tag in the shadow DOM as it will be cached you don't really have to worry about repeated style bloat.

#Events

Now we can hookup the events.  First we want a `pointerdown` event.  Why not `mousedown`?  Pointer events were created to be device agnostic so stylus, mouse or touch will all work and be handled the same.  It took a long while for Apple to finally come around but this should work in major browsers.  So let's register this:

```js
attachEvents() {
	this.dom.median.addEventListener("pointerdown", this.pointerdown);
}
pointerdown(e){
	this.isResizing = true;
        const clientRect = this.getBoundingClientRect();
	this.left = clientRect.x;
	this.top = clientRect.y;
	this.addEventListener("pointermove", this.resizeDrag);
	this.addEventListener("pointerup", this.pointerup);
}
```
When the user's device registers a "down" event we do a couple things.  We'll use the isResizing setter to put the element into a "resizing" state (we'll see this code in a little) which will allow us or the client to setup visual states when we're dragging the resize median.  We're also going to precalculate some values for top and left.  We could do this calculation in the drag handler but since the element itself shouldn't be moving while we do this we don't have to call `getBoundingClientRect()` for each `pointermove`. `getBoundingClientRect()` isn't cheap so this is good for performance.  We also add two more listeners, one that triggers and the user moves their pointer, and another that listens for an "up" event to tell us when to stop resizing.

Let's look at the other events:

```js
pointerup(){
	this.isResizing = false;
	this.removeEventListener("pointermove", this.resizeDrag);
	this.removeEventListener("pointerup", this.pointerup);
}
resizeDrag(e){
	if (this.direction === "row") {
		const newMedianLeft = e.clientX - this.left;
		const median = this.dom.median.getBoundingClientRect().width;
		this.style.gridTemplateColumns = `calc(${newMedianLeft}px - ${median / 2}px) ${median}px 1fr`;
	}
	if (this.direction === "column") {
		const newMedianTop = e.clientY - this.top;
		const median = this.dom.median.getBoundingClientRect().height;
		this.style.gridTemplateRows = `calc(${newMedianTop}px - ${median / 2}px) ${median}px 1fr`;
	}
}
```
Here's the meat of this whole component.  As the user moves their pointer after it's "down" we will update the inline style of the element to change the grid sizes.  We take the left of the element itself and subtract it from the current pointer x.  This gives us the offset from the left of the cursor and is where we want to position the end of the first panel.  The extra `median / 2` is the middle of the median.  This is not strictly necessary and it will function just fine as long as the median is small but this will more correctly position the panel based on where the user is dragging from and is better if the median is fat.  The two version are identical just with properties switch based on orientation.  The `pointerup` stop the resizing state and also removes itself and the move event.  This is necessary so we don't start event leaking which will cause all sorts of bugs.

You may also wonder if the mousemove should be debounced.  I tried this and it didn't seem to feel good at all so I would advise not, at least without better indication of what's happening.

Finally let's see what the resize property does:

```js
#isResizing = false;
set isResizing(value){
	if (value) {
		this.setAttribute("resizing", "");
	} else {
		this.removeAttribute("resizing");
	}
}
get isResizing(){
	return this.#isResizing;
}
```
Similar to the direction we use a private field incase we want to interrogate the state directly.  However, more useful is the styles we set based on the attribute.  Binary attributes should either exist or not, and when they exist they don't need a value to empty string is good.

We're also going to add a couple lines of CSS to the innerHTML styles:

```css
:host([resizing]){ user-select: none; cursor: col-resize; }
:host([resizing][direction=row]){ cursor: col-resize; }
:host([resizing][direction=col]){ cursor: row-resize; }
```
So now when we are resizing we'll get `user-select: none` which prevents you from selecting text and you drag the pointer around.  The cursor is to handle some edge cases.  As the pointer moves if it moves fast and things don't catch up it can wind up over content that is not the median, in this case we want to still retain the resize cursor in the correct orientation.

Those are internal events but what about public ones?  Well there's only really one that seems useful to me which is to know when the element has been resized.  We start with some boilerplate:

```js
function fireEvent(element, eventName, data, bubbles = true, cancelable = true) {
	const event = document.createEvent("HTMLEvents");
	event.initEvent(eventName, bubbles, cancelable); // event type,bubbling,cancelable
	event.data = data;
	return element.dispatchEvent(event);
}
```
Keep this one in your toolbelt or function library because it's a useful one that can be shared with other components.  We just need to call it when our event happens which is on `pointerup`:

```js
//pointerup
fireEvent(this, "sizechanged");
```
There's option argument for data. You could technically rename this to whatever you want and add other properties but I like to have just one property with all the event info called `data`.  I don't think it's necessary here, maybe we could return the new panel sizes but that's probably overthinking it right now.  The last two parameters are if it can bubble and cancel. Except in special cases I can't think of, I'd keep those `true`.

#Style API

We can give the user some generic style for the median but what if they want to provide their own?  We can do this use shadow parts.  On the median we simply add the part attribute `<div id="median" part="median"></div>`.  This will allow the user to style it using CSS like:

```css
wc-split-panel::part(median){
  color: red;
}
```
This will be at higher specificity than the defaults we set for median.

Lastly let's let the user set some defaults for the panel sizes, maybe 50-50 doesn't always make sense.  We can do this utilizing CSS variables.  Let's update the grid styles:

```css
:host([direction=row]) { grid-template-columns: var(--first-size, 1fr) max-content var(--second-size, 1fr); }
/*... lines ...*/
:host([direction=column]) { grid-template-rows: var(--first-size, 1fr) max-content var(--second-size, 1fr); }
```
What we've done here is let the custom properties `--first-size` and `--second-size` represent the panel sizes (initially).  The user can provide these like so:

```css
wc-split-panel {
  --first-size: 1fr;
  --second-size: 2fr;
}
```

If they don't, then we'll take the default values, 1fr and 1fr.  You might think you could do something like this to keep it more DRY:
```css
:host{ display: grid; --first-size: var(--first-size, 1fr); --second-size: var(--second-size, 1fr); }
```
This type of redefining CSS vars doesn't work, I think there was a bug or something.  Using different variable names will but I felt it was a cleaner without.

Why not use a CSS part here?  We don't really want that level of control.  They should control just the panel size but nothing else about it.

# Finished Code
And with that we have a completed split-panel.  A complete non-trivial custom element that uses shadow DOM, style APIs, events, property observation, attribute reflection, slots and a lot of modern web APIs.

{% codepen https://codepen.io/ndesmic/pen/eYzBvpV %}

# Bugs and Deficiencies

- When no direction is specified it does not work correctly, due to selectors specifically looking for `[direction]`.  This should ideally default to `row`.