DEV Community

loading...

How to make a resizable panel control with Web Components

ndesmic
I like to make fun web things from scratch. Ideally build-less, framework-less, infrastructure-less and free from the annoyances of my day job.
Updated on ・12 min read

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:

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;
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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:

static get observedAttributes(){
   return ["foo", "bar"];
}
Enter fullscreen mode Exit fullscreen mode

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.

  <slot id="slot1" name="1"></slot>
  <div id="median"></div>
  <slot id="slot2" name="2"></slot>
Enter fullscreen mode Exit fullscreen mode

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:

<wc-split-panel>
  <div slot="1">Panel 1 Content</div>
  <div slot="2">Panel 2 Content</div>
</wc-split-panel>
Enter fullscreen mode Exit fullscreen mode

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. 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.

#direction = "row
set direction(value){
  this.#direction = value;
  this.setAttribute("direction", value);
  this.style.gridTemplateRows = "";
  this.style.gridTemplateColumns = "";
}
get direction(){
  return this.#direction;
}
Enter fullscreen mode Exit fullscreen mode

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

: 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; }
Enter fullscreen mode Exit fullscreen mode

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:

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);
}
Enter fullscreen mode Exit fullscreen mode

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:

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`;
    }
}
Enter fullscreen mode Exit fullscreen mode

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:

#isResizing = false;
set isResizing(value){
    if (value) {
        this.setAttribute("resizing", "");
    } else {
        this.removeAttribute("resizing");
    }
}
get isResizing(){
    return this.#isResizing;
}
Enter fullscreen mode Exit fullscreen mode

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:

:host([resizing]){ user-select: none; cursor: col-resize; }
:host([resizing][direction=row]){ cursor: col-resize; }
:host([resizing][direction=col]){ cursor: row-resize; }
Enter fullscreen mode Exit fullscreen mode

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:

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);
}
Enter fullscreen mode Exit fullscreen mode

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:

//pointerup
fireEvent(this, "sizechanged");
Enter fullscreen mode Exit fullscreen mode

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:

wc-split-panel::part(median){
  color: red;
}
Enter fullscreen mode Exit fullscreen mode

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:

: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); }
Enter fullscreen mode Exit fullscreen mode

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:

wc-split-panel {
  --first-size: 1fr;
  --second-size: 2fr;
}
Enter fullscreen mode Exit fullscreen mode

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:

:host{ display: grid; --first-size: var(--first-size, 1fr); --second-size: var(--second-size, 1fr); }
Enter fullscreen mode Exit fullscreen mode

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.

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.

Discussion (6)

Collapse
dannyengelman profile image
Danny Engelman

All this bind is totally not required; all methods you declare in a class have the correct scope.

  this.bind(this);
  }
  bind(element){
    element.render = element.render.bind(element);
    element.cacheDom = element.cacheDom.bind(element);
    element.attachEvents = element.attachEvents.bind(element);
  }
Enter fullscreen mode Exit fullscreen mode


`

Collapse
ndesmic profile image
ndesmic Author

You're right. It actually comes from some boilerplate I copy-and-paste a lot and was not necessary here. I always go back-and-forth with consistency. Do I only bind things that might have issues like event handlers or do everything to be consistent about it?

Collapse
dannyengelman profile image
Danny Engelman • Edited

With lexical scope bind is old notation

Event handlers don't need bind either, when you call them properly:

this.onclick = (evt) => this.myHandler(evt)

and not:

this.onclick = this.myHandler.bind(this)

The latter sucks from a code readablity POV, you have no clue a vital parameter is passed

Thread Thread
ndesmic profile image
ndesmic Author • Edited

I don't think there's much of a difference in readability. Say for example myHandler doesn't use this at all. Would you write this.onclick = e => myHandler(e) to make it clear the event was passed or just this.onclick = myHandler? My general intuition is to try to keep it consistent and I generally prefer to not inline handlers (at least in the final product anyway) as I find it starts to clutter up the event handler registrations. To note things that are handlers versus plain methods I usually do by name onClick onPointerMove etc. so hopefully it would be apparent it takes an event parameter. These might be areas where I might switch to decorators whenever they start landing.

Collapse
dmondev profile image
dmondev

Hi ! Nice article !
It would be cool to provide a demo in a code pen.
Cheers !

Collapse
ndesmic profile image
ndesmic Author

Good idea, updated.