Contenteditable in Shadow DOM is broken: -> We fix it!
This post covers the main challenges with the Selection API and offers solutions for building a rich text editor in Shadow DOM components.
"LOL. Why did nobody tell me that a contenteditable
element in the Shadow DOM is broken? 🥹"
TL;DR
While working with the contenteditable
element in the Shadow DOM for the Inlang ecosystem, I encountered unexpected issues. This post covers the main challenges with the Selection API and offers solutions for building a rich text editor in Shadow DOM components.
Why did I stumble over this problem?
I was working on a UI library for the Inlang ecosystem, built as web components (why?). Everything was going great until the business required a rich text editor. When I placed a contenteditable
element inside a Shadow DOM, I noticed that the selection API wasn't working as expected. Problems occurred in all browsers.
First quick research round:
Quill: Support Shadow DOM
I was baffled. All my resources pointed to the Selection API as the source of the problem. How could something so fundamental be broken?
The Selection API: A Quick Primer
The Selection API allows developers to manipulate text selection on a webpage, enabling users to highlight, select, and interact with text naturally.
In a typical scenario, you might have a <div contenteditable= "true"> where users can type, select text, and apply styles like bold or italics. Under the hood, the Selection API is handling all the text selection and manipulation magic. Moving this to the Shadow DOM makes things less magical. 😕
The Shadow DOM
The Selection API only allows one selection per document. When using a shadow DOM, we create a scenario where multiple selections can appear. This is, of course, not desirable, but because the Selection API in the Shadow DOM is not standard, every browser handles this differently, which makes it hard to create a unified experience across browsers.
Why Isn't This Fixed Yet?
You might be wondering, "Why hasn't this been fixed?" The answer isn't simple. Web components are still evolving, and while we've gained more access to powerful APIs and frameworks like Lit, or web component support in React 19, the Shadow DOM remains a bit of a frontier.
There's currently a [proposal](https://github.com/mfreed7/shadow-dom-selection) to address some of these issues, but it's moving at the speed of web standards—a pace we all know can be slower than we'd like. This means that for now, developers have to find their own solutions or workarounds.
Workarounds: Making It Work
So, what do you do when you need to use a contenteditable
element in the Shadow DOM? Here are a two workarounds:
1. Monkey Patching: This involves manually adjusting the behavior of the Selection API within the Shadow DOM. It's not pretty and certainly not ideal, but it can get the job done in a pinch. You'd essentially override the default methods to make sure they behave as expected within your Shadow DOM context. Problem: It doesn't work for safari. (Resource)
/**
* We need to hack the window.getSelection method to use the shadow DOM,
* since the mobiledoc editor internals need to get the selection to detect
* cursor changes. First, we walk down into the shadow DOM to find the
* actual focused element. Then, we get the root node of the active element
* (either the shadow root or the document itself) and call that root's
* getSelection method.
*/
export function patchGetSelection() {
const oldGetSelection = window.getSelection.bind(window);
window.getSelection = (useOld: boolean = false) => {
const activeElement = findActiveElementWithinShadow();
const shadowRootOrDocument: ShadowRoot | Document = activeElement
? (activeElement.getRootNode() as ShadowRoot | Document)
: document;
const selection = (shadowRootOrDocument as any).getSelection();
if (!selection || useOld) return oldGetSelection();
return selection;
};
}
/**
* Recursively walks down the DOM tree to find the active element within any
* shadow DOM that it might be contained in.
*/
function findActiveElementWithinShadow(
element: Element | null = document.activeElement
): Element | null {
if (element?.shadowRoot) {
return findActiveElementWithinShadow(element.shadowRoot.activeElement);
}
return element;
}
2. Slotting the Editor: You can use slots to move the contenteditable
element outside of the Shadow DOM while keeping its visual and logical association with your component. However, this leads to loss of style encapsulation, requiring a unique class fallback.
<my-element>
<my-editor></my-editor>
</my-element>
import {LitElement, html} from 'lit';
import {customElement} from 'lit/decorators.js';
@customElement('my-element')
export class MyElement extends LitElement {
protected render() {
return html`
<p>
<slot></slot>
</p>
`;
}
}
import {LitElement, html} from 'lit';
import {customElement} from 'lit/decorators.js';
@customElement('my-editor')
export class MyElement extends LitElement {
createRenderRoot() {
return this;
}
protected render() {
return html`
<p contenteditable="true"></p>
`;
}
}
In this example I created a `my-editor` component that disables the rendering in shadow DOM and is slotted in a custom `my-element` component.
Conclusion
While working with the contenteditable
element in the Shadow DOM, I encountered a frustrating issue. Despite its limitations, the Shadow DOM is a powerful tool with immense potential. Until standards catch up, developers will have to find workarounds and remain adaptable. If you've faced similar issues or found clever workarounds, I'd love to hear about them.