Victorian fox looking at a computer.
Jack of all trad.es

a work in progress - dev & misc

A custom element to display an editable stylesheet

Writing up the generator for my very decidedly quotidian hot take on a CSS book generator, I ran into an application for a web component that I thought might prove useful.

How it should work

The element, named style-editor, should adopt the default styling of the code block it surrounds. It will leverage the contenteditable attribute to ensure its text content can be edited. As its connected to the DOM, the element will inject a stylesheet into the page that will be updated with any subsequent edits to the page. Taken all together this will mean that we should have what is effectively a displayed and editable stylesheet directly on the page.

It will not and should not have any persistent state, since that might have unintended security/userability consequences users. Because cursors are a bit of nightmare for contenteditable elements, we're just going to ignore that problem entirely.

<style-editor>
    <pre>
    <code>
        .example {
            color: red !important;
            font-size: 48px;
        }
    </pre>
</style-editor>

Hello, world!

Edit the rule below to inject a stylesheet that will modify the above text!

.hello-world {
    color: darkslateblue;
    font-family: cursive;
    font-size: 66px;
}

Ways to set global styles

Create a style element, modify its text content, and append it to the DOM.

const style = document.createElement("style");
style.textContent = "p { color: red !important; }";
document.documentElement.append(style);

Create a CSSStyleSheet object, set its content, add it to the document's adoptedStyleSheets.

const stylesheet = new CSSStyleSheet();
stylesheet.replaceSync("p { color: red !important; }");
document.adoptedStyleSheets.push(stylesheet);

What does it look like?

The scope of this short article is not "how to write a web component or define a custom element." If that's your interest, check out Rob Eisenberg's "Hello Web Components" instead.

Here's what our web component ends up looking like.

class StyleEditor extends HTMLElement {
  stylesheet = new CSSStyleSheet(); // instantiate a new stylesheet for this custom element

  constructor() {
    super(); // always call super!
    document.adoptedStyleSheets.push(this.stylesheet); // push the stylesheet into the document's stylesheets
  }

  connectedCallback() {
    this.contentEditable = true; // allow the node to be editable
    this.style.fontFamily = "monospace"; // make font look "code"-like
    this.stylesheet.replaceSync(this.textContent); // add
    this.style.outline = "none !important";

    // listen for "input" event fired by `contenteditable` elements update stylesheet on input
    // considered using MutationObserver instead but opted for this in the end
    this.addEventListener(
      "input",
      debounce(() => this.stylesheet.replaceSync(this.textContent)) // a debounce function below
    );
  }
  // [... see Listeners section below]
}

Listeners

If you are an astute tester and using only the code above, you'll notice that every time you press enter, a new parent elements is inserted into the DOM.

This doesn't look so good, especially on my site, where the outer element is styled. Effectively, pressing enter on our style-editor creates a new code block, which isn't what we want at all. This problem, I admit, took me to StackOverflow, where I was lucky enough to find a question with a number of exisiting answers. I took this one, modified it very slightly to add the listener to our custom element rather than on the window.

Warning! document.execCommand is a deprecated API. It's unwise of me to use it here. But to be honest, it is such an easy fix, and has such good caniuse coverage at the time I am writing this, so I figured I would use it anyway.

this.addEventListener("keydown", (event) => {
  if (!event.shiftKey && event.key === "Enter") {
    document.execCommand("insertLineBreak");
    event.preventDefault();
  }
});

A note on tabs and focus

On the web, tabs are used to control keyboard focus. For this very reason, I chose not to write any code that hijacks the tab key. This means style-editor deviates slightly from normal IDE tab behavior for indents. I think that is okay given the alternative is essentially hijacking focus in a way that might negatively impact folks who need to use the keyboard to navigate. The other alternative is also not tenable: writing a very more complex API and figuring out some way to inform users of what it is. VSCode for example, uses Ctrl + m to switch between tab-to-indent behavior and tab-to-cycle-focused-element behavior. This is good for them. Not for me. Simply too much work for too little pay off!

A debounce function

You'll also note that our input event listener's callback uses a debounce function. This ensure that we are batching our stylesheet updates. Read more about debounce functions in JavaScript.

function debounce(callback, timeout = 500) {
  let timer;
  return (...args) => {
    clearTimeout(timer);
    timer = setTimeout(() => callback.apply(null, args), timeout);
  };
}

Define the custom element

This one-liner adds our custom element to the DOM.

customElements.define("style-editor", StyleEditor);

Gimme all the code at once, dang it!

Fair enough. This article is a bit like one of those annoying recipe pages and for that I apologize! Here's the code:

function debounce(callback, timeout = 500) {
  let timer;
  return (...args) => {
    clearTimeout(timer);
    timer = setTimeout(() => callback.apply(null, args), timeout);
  };
}

class StyleEditor extends HTMLElement {
  stylesheet = new CSSStyleSheet();

  constructor() {
    super();
    document.adoptedStyleSheets.push(this.stylesheet);
  }

  connectedCallback() {
    this.contentEditable = true;
    this.style.fontFamily = "monospace";
    this.stylesheet.replaceSync(this.textContent);

    this.addEventListener(
      "input",
      debounce(() => this.stylesheet.replaceSync(this.textContent))
    );

    // needs to handler for enter (without shift), to prevent creation of new pre > code element
    this.addEventListener("keydown", (event) => {
      if (!event.shiftKey && event.key === "Enter") {
        document.execCommand("insertLineBreak");
        event.preventDefault();
      }
    });
  }
}

customElements.define("style-editor", StyleEditor);