Skip to content
Open
Show file tree
Hide file tree
Changes from 122 commits
Commits
Show all changes
161 commits
Select commit Hold shift + click to select a range
8e24a27
[lexical][lexical-clipboard][lexical-playground] Feature: Support DOM…
mayrang Jun 13, 2026
4132b75
[lexical] Add getComposedEventTarget helper for composed-event target…
mayrang Jun 13, 2026
b796e82
[lexical][lexical-utils][lexical-table][lexical-devtools][lexical-rea…
mayrang Jun 13, 2026
036fe77
[lexical] Shadow DOM: extend ShadowRootSelection browser tests for au…
mayrang Jun 13, 2026
dd207d5
[lexical-playground] Shadow DOM: extend e2e for table click, slash me…
mayrang Jun 13, 2026
dc5dec0
[lexical] Shadow DOM: audit round 2 — document selection-points read …
mayrang Jun 13, 2026
cd5128d
[lexical-playground] Shadow DOM: audit round 2 — narrow popup root ca…
mayrang Jun 13, 2026
a87a82d
[lexical] Shadow DOM: audit round 2 — convert ShadowRootSelection bro…
mayrang Jun 13, 2026
33c17d5
[examples] Shadow DOM: audit round 2 — gate web-component form dispat…
mayrang Jun 13, 2026
b7cfc7f
[lexical][examples] Shadow DOM: audit round 3 — restore test fail-fas…
mayrang Jun 13, 2026
efbcf66
[lexical][lexical-clipboard] Shadow DOM: audit round 5 — guard caretF…
mayrang Jun 13, 2026
e707baf
[lexical] Shadow DOM: audit round 5 — add IME composition end smoke t…
mayrang Jun 13, 2026
f31a47e
[lexical-playground][*] Shadow DOM: audit round 6 — guard caretFromPo…
mayrang Jun 13, 2026
2fc3599
[lexical-react][lexical-playground][examples] Shadow DOM: audit round…
mayrang Jun 13, 2026
9035d95
[lexical-playground][lexical] Shadow DOM: follow-up code review — def…
mayrang Jun 13, 2026
f69c0af
[lexical] Shadow DOM: code-quality review — uniform @experimental, re…
mayrang Jun 13, 2026
bbed451
[lexical][lexical-clipboard][lexical-table][lexical-react][lexical-pl…
mayrang Jun 14, 2026
65b9432
[lexical][lexical-playground] Shadow DOM: code-quality review — add g…
mayrang Jun 14, 2026
31de3d0
[lexical][lexical-table][lexical-utils][lexical-playground] Shadow DO…
mayrang Jun 14, 2026
6abf564
[lexical] Shadow DOM: code-quality review — adopt playbook selection …
mayrang Jun 14, 2026
4720d9b
[lexical] Shadow DOM: ShadowRootSelection cross-browser fix — Chromiu…
mayrang Jun 14, 2026
ee96474
[lexical-playground] Shadow DOM: e2e + util fixes for the playground …
mayrang Jun 14, 2026
3f68959
[lexical-playground] Shadow DOM: stabilize copy/paste e2e under CI ti…
mayrang Jun 14, 2026
d2a58c8
[lexical-playground] Shadow DOM: replace timeout with text-rendered w…
mayrang Jun 14, 2026
efe7e58
[lexical-playground] e2e utils: narrow the shadow-piercing editor loo…
mayrang Jun 14, 2026
b227dcb
[lexical-playground] Shadow DOM: add diagnostic logs to copy/paste e2e
mayrang Jun 14, 2026
72c78e1
[lexical-playground] Shadow DOM: expand copy/paste e2e diagnostics
mayrang Jun 14, 2026
8c8bc66
[lexical-playground] Shadow DOM: log composed selection right before …
mayrang Jun 14, 2026
2077dbf
[lexical-playground] Shadow DOM: re-focus the shadow editor before mo…
mayrang Jun 14, 2026
0d1c504
[lexical-playground] Shadow DOM: collapse selection via DOM API inste…
mayrang Jun 14, 2026
1065fbd
[lexical-playground] Shadow DOM: use setBaseAndExtent for WebKit shad…
mayrang Jun 14, 2026
96e8f9f
[lexical-playground] Shadow DOM: remove copy/paste e2e diagnostics
mayrang Jun 14, 2026
b56a05f
[lexical-website] Documentation: expand the Shadow DOM concept page
mayrang Jun 14, 2026
03a2bc6
[lexical-website] Documentation: tighten Shadow DOM page prose
mayrang Jun 14, 2026
829303d
[lexical] Shadow DOM: extend browser-unit coverage — 3-level walk, cl…
mayrang Jun 14, 2026
b19c672
[lexical-website] Documentation: cross-link Shadow DOM page from dom-…
mayrang Jun 14, 2026
5ba2ffc
[examples] Shadow DOM: form-associated callbacks, slot toolbar, thema…
mayrang Jun 14, 2026
d929f37
[lexical-website] Shadow DOM: document inherited CSS, popover layout,…
mayrang Jun 14, 2026
ead9f35
[examples] Shadow DOM: user-preference media queries, dir inheritance…
mayrang Jun 14, 2026
83c8a8d
[examples] Shadow DOM: form lifecycle callbacks, state restore, inert…
mayrang Jun 14, 2026
197ccc8
[lexical-playground] Shadow DOM: image, NodeSelection, focus, IME and…
mayrang Jun 14, 2026
24a5b9f
[examples][lexical-playground] Shadow DOM: DOM-move state round-trip,…
mayrang Jun 14, 2026
0ba9af5
[lexical-website][examples] Shadow DOM: document DOM-move state round…
mayrang Jun 14, 2026
09318ca
[examples][lexical-playground] Shadow DOM: declarative SDDD reuse, ha…
mayrang Jun 14, 2026
ad01ebe
[examples][lexical-playground] Shadow DOM: parts / spellcheck / print…
mayrang Jun 14, 2026
30a8e52
[examples] Shadow DOM: contain: layout style on the host
mayrang Jun 14, 2026
cc46342
[examples] Shadow DOM: IndexedDB persistence on the host
mayrang Jun 14, 2026
c4f1ff7
[examples] Shadow DOM: opt-in lazy build via loading="lazy" + Interse…
mayrang Jun 15, 2026
97f3def
[examples] Shadow DOM: re-broadcast online / offline as a composed event
mayrang Jun 15, 2026
86318ec
[examples] Shadow DOM: README — note framework wrappers (Lit/Stencil)…
mayrang Jun 15, 2026
b6b194f
[examples] Shadow DOM: custom element states, ResizeObserver, Page Vi…
mayrang Jun 15, 2026
1a91132
[examples] Shadow DOM: popovertarget, <dialog> instance, dragstart co…
mayrang Jun 15, 2026
f41b706
[examples] Shadow DOM: BroadcastChannel, host.print(), Web Animations…
mayrang Jun 15, 2026
adb5b9f
[examples] Shadow DOM: adoptedStyleSheets, slot dynamics, ElementInte…
mayrang Jun 15, 2026
b71fab8
[examples] Shadow DOM: dirty / beforeunload / pagehide / pageshow / p…
mayrang Jun 15, 2026
d1e9e52
[examples] Shadow DOM: README — note CSS layout primitives held back
mayrang Jun 15, 2026
4de884f
[examples] Shadow DOM: getRootNode composed / elementsFromPoint shado…
mayrang Jun 15, 2026
a9579e8
[examples] Shadow DOM: requestIdleCallback wrap, navigator.locks, for…
mayrang Jun 15, 2026
c885eec
[examples] Shadow DOM: README — broaden carve-out to three CSS surfac…
mayrang Jun 15, 2026
e6e5bb2
[examples] Shadow DOM: drop the broader Web Platform API surface from…
mayrang Jun 15, 2026
f38e41e
[lexical] Shadow DOM: code-quality review — extract getDOMSelectionPo…
mayrang Jun 15, 2026
b3adcb3
[lexical] Shadow DOM: thread Selection.direction through DOMSelection…
mayrang Jun 15, 2026
07575fb
[lexical-clipboard] Shadow DOM: thread rootElement through the caretF…
mayrang Jun 15, 2026
b15dc87
[lexical-playground] Shadow DOM: drop the mirrored stylesheets on cle…
mayrang Jun 15, 2026
3078f9c
[lexical-playground] Shadow DOM e2e: extract __findShadowEditor, drop…
mayrang Jun 15, 2026
58b0d51
[lexical] Shadow DOM: correct browser-support table and walk back the…
mayrang Jun 15, 2026
73374d8
[examples] Shadow DOM: nest a <lexical-editor> inside a second shadow…
mayrang Jun 15, 2026
e5407e4
[lexical-playground] Shadow DOM: keep .editor-shell inside the shadow…
mayrang Jun 15, 2026
b9bd0df
[lexical-playground] Shadow DOM: keep the mirrored stylesheets in syn…
mayrang Jun 15, 2026
6dd1985
[examples] Shadow DOM (React): line-height on the placeholder so its …
mayrang Jun 15, 2026
b318c4a
[examples] Shadow DOM web component: drop the incomplete RTL toggle a…
mayrang Jun 15, 2026
c58ff85
[examples] Shadow DOM web component tests: drop the RTL assertion and…
mayrang Jun 15, 2026
c198905
[lexical-playground] Shadow DOM e2e: skip the synthetic ClipboardEven…
mayrang Jun 15, 2026
bd354db
[examples] Shadow DOM web component tests: cover the floating popover…
mayrang Jun 15, 2026
dad27d6
[lexical-website][examples] Shadow DOM docs: sync the README + concep…
mayrang Jun 15, 2026
6b30117
[examples][lexical-playground] Shadow DOM CI: install Playwright's ch…
mayrang Jun 15, 2026
3971e23
[ci] re-trigger workflows after cache reservation race
mayrang Jun 15, 2026
ab9faa7
[lexical] Shadow DOM coexisting editors: resolve active editor from d…
mayrang Jun 17, 2026
b573dc9
[examples] Shadow DOM web component: bind the summary toggles to form…
mayrang Jun 17, 2026
a1f2e27
[examples] Shadow DOM web component: use editor.read for the plain-te…
mayrang Jun 17, 2026
203f374
[lexical] Shadow DOM round 2 review: cross-realm and shadow-aware checks
mayrang Jun 17, 2026
52b49c3
[lexical] Shadow DOM: attribute selectionchange via per-editor compos…
mayrang Jun 17, 2026
b934327
[lexical] Shadow DOM round 2 review: extract shared shadow-piercing +…
mayrang Jun 17, 2026
48b2842
[lexical] Shadow DOM: register round-2 new exports in Lexical.js.flow
mayrang Jun 17, 2026
2cd801f
[lexical] Shadow DOM: match nested editor by anchor's nearest editor
mayrang Jun 17, 2026
962c5f9
[lexical-utils][lexical-react] Shadow DOM round 2 review: shadow-awar…
mayrang Jun 17, 2026
e535fb8
[lexical-react] Shadow DOM round 2 review: key useDynamicPositioning …
mayrang Jun 17, 2026
2e73db9
[lexical-selection][lexical-clipboard] Shadow DOM: realm-aware Range …
mayrang Jun 17, 2026
ebbfed8
[lexical] Shadow DOM round 2 review: consolidate $updateDOMSelection'…
mayrang Jun 17, 2026
c77acf0
[lexical] Shadow DOM round 2 review: extract getRootOwnerDocument helper
mayrang Jun 17, 2026
c680cb5
[lexical] Shadow DOM round 2 review: restore JSDoc adjacency + add qu…
mayrang Jun 17, 2026
352edec
[lexical][lexical-utils][lexical-react] Shadow DOM round 2 review: re…
mayrang Jun 17, 2026
145e785
[lexical] Shadow DOM round 2 review: lock useDynamicPositioning's roo…
mayrang Jun 17, 2026
59d63e7
[lexical-react] Shadow DOM round 2 review: add production-level Comme…
mayrang Jun 17, 2026
6b0e310
[lexical-selection] Shadow DOM round 2 review: route createDOMRange t…
mayrang Jun 17, 2026
99f6c37
[lexical] Shadow DOM round 2 review: document the light-outer/shadow-…
mayrang Jun 17, 2026
8291fb4
[examples] Shadow DOM web component: mirror the form reset sync test …
mayrang Jun 17, 2026
e74d65d
[lexical-playground][lexical] Shadow DOM tests: drop browser-primitiv…
mayrang Jun 17, 2026
3aa5533
Merge remote-tracking branch 'origin/main' into feat/8660-shadow-dom
etrepum Jun 19, 2026
6f7353a
Merge remote-tracking branch 'origin/main' into feat/8660-shadow-dom
etrepum Jun 19, 2026
23f719c
Merge branch 'main' into feat/8660-shadow-dom
etrepum Jun 19, 2026
43a9067
explicit editor read mode
etrepum Jun 19, 2026
0a92950
Use RefCallback to establish ShadowRoot
etrepum Jun 19, 2026
7a662bf
Apply RefCallback simplification to playground ShadowDomWrapper
etrepum Jun 19, 2026
4d11ba2
[lexical-playground] Test: assert ShadowDomWrapper uses adoptedStyleS…
mayrang Jun 20, 2026
35c6bde
[lexical-playground] ShadowDomWrapper: adopt document stylesheets via…
mayrang Jun 20, 2026
bba7f1e
[lexical-playground] Test: HMR-style text update refreshes adopted sheet
mayrang Jun 20, 2026
2954135
[examples] Shadow DOM web component: use ownerDocument for createElement
mayrang Jun 20, 2026
35e79ba
[lexical-react] Refactor: extract shared getScrollParent helper
mayrang Jun 20, 2026
e568418
[lexical] Perf: defer getRangeAt(0) in updateDOMSelection's hot path
mayrang Jun 20, 2026
f85280b
[lexical] Bug Fix: Use getActiveElementDeep at the updateDOMSelection…
mayrang Jun 20, 2026
7aa1ad4
[lexical] Bug Fix: Resolve composed target for pointerdown / beforein…
mayrang Jun 20, 2026
7d2d6ef
[lexical-playground] ShadowDomWrapper: strip @import rules before rep…
mayrang Jun 20, 2026
7d8d171
[lexical] Bug Fix: getParentElement crosses ShadowRoot to host
mayrang Jun 20, 2026
721124e
[lexical-clipboard] Bug Fix: caretFromPoint walks composed ancestor c…
mayrang Jun 20, 2026
32d3350
[lexical] Bug Fix: Attribute selectionchange to shadow-mounted editor…
mayrang Jun 20, 2026
7ae7c31
[examples] Shadow DOM nested case: light-DOM outer with shadow-mounte…
mayrang Jun 20, 2026
eaa0350
[lexical-playground] ShadowDomWrapper: batch adoption, case-insensiti…
mayrang Jun 20, 2026
007848a
[examples] Shadow DOM tests: fix bold and word-delete for nested edit…
mayrang Jun 20, 2026
4e955ed
[lexical-react] Bug Fix: Shadow-aware menu anchor, drag block zoom, a…
mayrang Jun 20, 2026
b956724
[lexical] Perf: Cache anchorNode in onDocumentSelectionChange to avoi…
mayrang Jun 20, 2026
c82b0d9
[lexical] Perf: Fast path for onDocumentSelectionChange when no edito…
mayrang Jun 20, 2026
60e69f9
(hopefully) disable vercel toolbar, specialize querySelectorAllDeep t…
etrepum Jun 20, 2026
3f3057b
[lexical] Shadow DOM: resolve composed range for scroll-into-view
claude Jun 20, 2026
448131a
[lexical] Shadow DOM: attribute Firefox focus restore via deep active…
claude Jun 20, 2026
af7704a
[lexical-utils] Shadow DOM: read calculateZoomLevel styles from eleme…
claude Jun 20, 2026
68c8088
[lexical-react] Shadow DOM: use getComposedEventTarget for click and …
mayrang Jun 21, 2026
d84d118
[lexical] Perf: deduplicate getDOMSelectionPoints in input handler
mayrang Jun 21, 2026
5e139ad
[lexical-playground] Shadow DOM: use getComposedEventTarget in TableH…
mayrang Jun 21, 2026
6af8ea8
[lexical-playground] Shadow DOM: use getComposedEventTarget in CodeAc…
mayrang Jun 21, 2026
d15d548
[lexical-clipboard] Bug Fix: Reject retargeted caret in caretFromPoin…
mayrang Jun 21, 2026
e9d7c43
[lexical] Perf: replace Array.from+sort with single-pass partition in…
mayrang Jun 21, 2026
84a8a23
Fix prettier formatting in LexicalSelection.ts
mayrang Jun 21, 2026
f142918
Fix ShadowDOM e2e: pass actual editor coordinates to synthetic DragEvent
mayrang Jun 21, 2026
fc75a6a
Fix caretFromPoint: only guard fallback when shadow-aware path was av…
mayrang Jun 21, 2026
641d283
[lexical-clipboard] Shadow DOM: add elementFromPoint+text offset fall…
mayrang Jun 21, 2026
1e7b601
Fix e2e: supply real coordinates in synthetic DragEvent for shadow dr…
mayrang Jun 21, 2026
b5bcbd4
Fix selectionchange fast path: detect decorator-internal shadow hosts
mayrang Jun 21, 2026
98e2c84
Shadow DOM: fix remaining leaks from code review audit
mayrang Jun 21, 2026
0fd4849
[lexical-clipboard] Shadow DOM: fall back instead of dropping the caret
claude Jun 21, 2026
5d25628
[lexical-react] Shadow DOM: composed event target in drag block handlers
claude Jun 21, 2026
42cda7f
[lexical] Shadow DOM: resolve walker document by nodeType, not instan…
claude Jun 21, 2026
b54b59a
[lexical][lexical-playground] Shadow DOM: deduplicate getActiveElemen…
mayrang Jun 21, 2026
f57c4dd
[lexical][lexical-react] Shadow DOM tests: unify skip pattern and cle…
mayrang Jun 21, 2026
0023249
correct findAllLexicalElementsDeep flow type
etrepum Jun 21, 2026
287fcbd
Merge remote-tracking branch 'origin/main' into feat/8660-shadow-dom
etrepum Jun 21, 2026
aa28f1f
move .env to root to hopefully disable vercel toolbar
etrepum Jun 21, 2026
b99ef13
[lexical-clipboard] Bug Fix: vertical-first comparison in findTextOff…
mayrang Jun 21, 2026
1c1f561
fix: widen wrap margin in vertical-first browser test for Windows CI
mayrang Jun 21, 2026
98df831
[lexical-yjs] Bug Fix: Inject CSS Highlight API stylesheet into shado…
mayrang Jun 21, 2026
245ecdd
[lexical-playground] Fix: Stabilize CollaborationPlugin across shadow…
mayrang Jun 21, 2026
f36eeb8
Merge remote-tracking branch 'origin/main' into feat/8660-shadow-dom
etrepum Jun 21, 2026
e16e991
[lexical-clipboard] Shadow DOM: keep in-editor caret for text-less dr…
claude Jun 21, 2026
28677cb
remove failed .env experiment to remove the vercel toolbar
etrepum Jun 21, 2026
040df98
Revert "[lexical-clipboard] Shadow DOM: keep in-editor caret for text…
etrepum Jun 21, 2026
1d102ee
[lexical-yjs] Shadow DOM: re-home cursor highlight sheet on root scop…
claude Jun 21, 2026
f895579
Merge remote-tracking branch 'origin/main' into feat/8660-shadow-dom
etrepum Jun 21, 2026
540a493
Fix getCursorHighlightSheet test types
etrepum Jun 21, 2026
775bba5
[lexical] Shadow DOM: per-document registration with cached shadow-ed…
claude Jun 21, 2026
d3f6c76
Merge tag 'mainline' into claude/lexical-pr-8694-review-xt661e
claude Jun 21, 2026
306b139
vercel redeploy
etrepum Jun 21, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
169 changes: 169 additions & 0 deletions dev-examples/shadow-dom-web-component/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
# Lexical Web Component (Shadow DOM) Example

A framework-free [Vite](https://vitejs.dev/) app that packages a Lexical
rich-text editor as a **custom element** (`<lexical-editor>`) whose toolbar,
styles, and `contentEditable` all live inside an **open `ShadowRoot`** —
the scenario from
[facebook/lexical#2119](https://github.com/facebook/lexical/issues/2119),
[#6709](https://github.com/facebook/lexical/issues/6709), and
[#8125](https://github.com/facebook/lexical/issues/8125).

Where the sibling [`shadow-dom`](../shadow-dom) example demonstrates a React
app with the editor in a shadow root and the toolbar outside it, this one
demonstrates the inverse packaging: a fully self-contained web component.
The demo page mounts four instances — three light-DOM `<lexical-editor>`
hosts inside the form (a `required` notes editor, a themable summary editor,
and a pre-rendered editor that hydrates from `<template shadowrootmode="open">`)
plus a fourth instance inside a wrapper `<div>` that opens its own shadow root,
so the editor's contentEditable sits two shadow boundaries below the document
and exercises the multi-level walk through `getDOMShadowRoots`. Everything
runs on platform APIs only:

- `Element.attachShadow({mode: 'open'})` in `connectedCallback`, with the
editor built by `@lexical/extension`'s `buildEditorFromExtensions` and torn
down (`editor.dispose()`) in `disconnectedCallback`.
- **Form association** through the standard
[`ElementInternals`](https://developer.mozilla.org/docs/Web/API/ElementInternals)
API — each editor submits its serialized editor state with the surrounding
`<form>`, no hidden `<input>` required.
- **`required` validation** through
[`ElementInternals.setValidity`](https://developer.mozilla.org/docs/Web/API/ElementInternals/setValidity),
driven by the editor's plain text content. A `<lexical-editor required>`
participates in `form.checkValidity()` and the browser's native invalid-form
UI just like a `<textarea required>`.
- **`disabled` and `readonly` attributes** that flip Lexical's editable
state. `disabled` also drops the editor out of `FormData` and skips
validation, matching `<input disabled>`; the
[`formDisabledCallback`](https://developer.mozilla.org/docs/Web/API/Web_components/Using_custom_elements#form-associated_callbacks)
picks up an ancestor `<fieldset disabled>` automatically.
- **CSS custom property theming** — the editor exposes `--lexical-bg`,
`--lexical-fg`, and a small palette of toolbar variables on its host
element. The page redefines them to recolour the editor; inherited
custom properties cross the shadow boundary on their own, so the
internal layout stays private.
- **Toolbar slot** — `<button slot="toolbar-extra">` projects a
light-DOM button into the editor's toolbar row. The button stays in
the page (its click never crosses the boundary), but the page can drive
the editor through the host's public API.
- **Floating selection popover** — the editor emits a composed
`lexical-selection-rect` `CustomEvent` carrying the live viewport
rect of the selection inside its shadow root, computed through
Lexical's `getDOMSelectionRangeAndPoints`. A page-level
[popover](https://developer.mozilla.org/docs/Web/API/Popover_API)
positions itself from those coordinates and drives bold / italic /
underline back through the host's editor.
- A composed `input` event that crosses the shadow boundary so the page can
observe edits.
- Selection inside the shadow root is resolved by Lexical itself via
`Selection.getComposedRanges` / `Selection.direction`, and focus via
`ShadowRoot.activeElement`.

## Running

From the repository root:

```sh
pnpm install
pnpm -C dev-examples/shadow-dom-web-component dev
```

Then open the printed URL. Try:

- Typing in any of the four editors and switching between them (each keeps
its own selection and history).
- Selecting words with `Alt`/`Ctrl` + `Shift` + arrow keys, then using the
in-shadow toolbar buttons — they reflect the selection's formats.
- Word/line deletion with `Alt`/`Ctrl` + `Backspace`/`Delete`.
- Submitting the form to see each editor's serialized state in the output.
- Submitting the form while the first editor (`required`) is empty: the
browser blocks the submit and surfaces its native validation tooltip on
the editor.
- Clicking the **Clear** button next to the notes toolbar — that button
lives in the light DOM and is projected through the `toolbar-extra`
slot, then drives the editor through the host's public API.
- Toggling the **Lock the summary editor** checkbox to flip the
`readonly` attribute on the summary editor, then trying to type
inside it: the contentEditable refuses input, but the form still
submits the value.
- Selecting a few words in either editor — the floating B / I / U
popover appears anchored under the selection (in the page, not the
shadow root), and clicking it formats the text through the editor.
- Switching the OS or browser between light and dark mode (or
redefining `--lexical-bg`, `--lexical-fg`, etc. in the page CSS) to
see how page-side CSS variables retheme each editor without touching
its shadow root.

## Tests

[Playwright](https://playwright.dev/) tests in [`tests/`](./tests) cover the
editors rendering in independent shadow roots (including the nested
editor inside the wrapper shadow root), typing and formatting, editor
independence, word deletion, `ElementInternals` form association, the
composed `input` event crossing the shadow boundary, and the floating
popover anchoring to a selection inside the nested shadow root. They
start the dev server automatically:

```sh
pnpm -C dev-examples/shadow-dom-web-component exec playwright install chromium
pnpm -C dev-examples/shadow-dom-web-component test
```

## What's covered

This example aims to be a full reference a production user can copy out.
The Playwright suite covers each of the surfaces above plus a second
round of audit items:

- DOM-move state round-trip (`disconnectedCallback` caches the
serialized state, `connectedCallback` restores it)
- `delegatesFocus: true` on the shadow root + `tabindex="0"` on the
contentEditable
- `host.setCustomValidity()` + the standard `validity` / `willValidate`
/ `checkValidity` / `reportValidity` surface
- `formAssociatedCallback` + `host.form`, `formResetCallback`,
`formDisabledCallback`, `formStateRestoreCallback` (bfcache / autocomplete)
- The standard `inert` attribute, `aria-label` / `aria-invalid` /
`role="textbox"` mirroring
- A composed `lexical-validity-change` event for a visible error
message
- `@media (prefers-color-scheme: dark)` / `(prefers-reduced-motion)` /
`(forced-colors: active)` inside the shadow stylesheet
- Declarative shadow DOM (`<template shadowrootmode>`) — the third
editor on the demo page pre-renders its shadow content and our
`connectedCallback` reuses the existing `.content` element instead
of creating a fresh contentEditable
- Hardened lifecycle: a duplicate `customElements.define` of the
same tag throws `NotSupportedError` (the shipped helper guards
against this); a host that fails to build doesn't crash the
surrounding page

The playground e2e suite (`packages/lexical-playground/__tests__/e2e/ShadowDOM.spec.mjs`)
covers the corresponding playground-side surfaces: markdown shortcuts
(`# heading`, `- list`) and `@lexical/list` inside the shadow root,
`@lexical/history` undo/redo, the tree-view mirror, pointer events
(`composedPath` recovery for touch / pen / mouse), HTML paste
sanitization (a `<script>` tag is stripped), a large keyboard input
that keeps the reconciler responsive, image insert + paste,
NodeSelection on image click, blur + re-focus through the shadow
boundary, Korean and Chinese IME composition cycles, and yjs
convergence between two clients each rendered inside its own open
shadow root.

`lexical-devtools` descends through open shadow roots using the same
helpers this PR adds (`getDOMShadowRoots` / `getActiveElementDeep` /
`getEditorPropertyFromDOMNode`), so it resolves the shadow-mounted
editor without dev-example-side glue. Closed-mode shadow roots remain
opaque to a page-level integration by spec — the concept page documents
the limitation and the browser-unit suite verifies the helpers behave
correctly when a host attaches a closed root.

A handful of newer platform APIs are not exercised by this single
example because they slot in at the page layer rather than the editor
itself: the
[View Transitions API](https://developer.mozilla.org/docs/Web/API/View_Transitions_API)
animates between shadow-mounted instances on `document.startViewTransition`
without any editor-side glue, and the
[CSS Custom Highlight API](https://developer.mozilla.org/docs/Web/API/CSS_Custom_Highlight_API)
(`Highlight` + `::highlight()`) styles ranges that span the shadow
boundary as long as the page hands them un-retargeted boundary points —
the same shape `getDOMSelectionRangeAndPoints` already returns.
118 changes: 118 additions & 0 deletions dev-examples/shadow-dom-web-component/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Lexical Web Component (Shadow DOM) Example</title>
</head>
<body>
<div class="App">
<h1>Lexical as a Web Component</h1>
<p class="App-blurb">
Two independent
<code>&lt;lexical-editor&gt;</code>
custom elements, each fully encapsulated in its own open shadow root —
toolbar, styles and
<code>contentEditable</code>
included. No React, no frameworks. They are form-associated via
<code>ElementInternals</code>
, so submitting the form below collects each editor's serialized state.
The first editor uses the standard
<code>required</code>
attribute, so the form refuses to submit while it is empty. The second
editor is themed entirely from the page through inherited CSS custom
properties — the editor's internal styles stay private, but the page can
recolour every surface from outside.
</p>
<form id="demo-form">
<label>
Notes
<small>(required)</small>
<lexical-editor
name="notes"
placeholder-text="Try typing, selecting words, and formatting…"
required
aria-label="Notes editor">
<button slot="toolbar-extra" type="button" data-clear>Clear</button>
</lexical-editor>
<span class="error" id="notes-error" hidden aria-live="polite">
This field can't be empty.
</span>
</label>
<label>
Summary
<lexical-editor
name="summary"
placeholder-text="A second editor, in its own shadow root."
aria-label="Summary editor"></lexical-editor>
</label>
<label class="control">
<input id="summary-readonly" type="checkbox" />
Lock the summary editor (sets the standard
<code>readonly</code>
attribute)
</label>
<label class="control">
<input id="summary-inert" type="checkbox" />
Make the summary editor
<code>inert</code>
(focus, pointer events and selection skip the subtree — crosses the
shadow boundary on its own)
</label>
<p id="last-edited">Last edited: (none)</p>
<button type="submit">Submit form</button>
<button type="reset">Reset form</button>
</form>
<pre id="form-output"></pre>
<hr />
<!--
Declarative Shadow DOM: the HTML parser attaches the open shadow
root from the inline <template> before our element's
connectedCallback runs. The element walks in already wearing a
`.content` div so we reuse it instead of creating a fresh
contentEditable. The editor's initial state still replaces the
pre-rendered children — `@lexical/html`'s
`$generateNodesFromDOM` would let a downstream user import that
content; this example just notes the integration point.
-->
<label>
Pre-rendered (declarative shadow DOM)
<lexical-editor name="prerendered" placeholder-text="Hydrated.">
<template shadowrootmode="open">
<div class="content" data-prerendered="true">pre-rendered text</div>
</template>
</lexical-editor>
</label>
<hr />
<!--
Nested shadow roots: a wrapper <div> attaches its own open shadow
root, and a <lexical-editor> mounts inside *that* shadow tree. The
editor's contentEditable is now two shadow boundaries below the
document, exercising getDOMShadowRoots' multi-level walk and the
per-shadow-root scroll listeners.
-->
<label>
Nested shadow (page → wrapper shadow → editor shadow)
<div id="nested-host"></div>
</label>
</div>
<!--
A light-DOM floating popover that anchors to the live selection rect
inside either editor's shadow root. The page never reaches into the
shadow root — it positions itself from coordinates the editor emits
through a composed CustomEvent.
-->
<div id="format-popover" popover="manual">
<button type="button" aria-label="Bold" data-format="bold">
<b>B</b>
</button>
<button type="button" aria-label="Italic" data-format="italic">
<i>I</i>
</button>
<button type="button" aria-label="Underline" data-format="underline">
<u>U</u>
</button>
</div>
<script src="/src/main.ts" type="module"></script>
</body>
</html>
29 changes: 29 additions & 0 deletions dev-examples/shadow-dom-web-component/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
{
"name": "@lexical/dev-shadow-dom-web-component-example",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
"test": "playwright install chromium && playwright test"
},
"dependencies": {
"@lexical/extension": "workspace:*",
"@lexical/history": "workspace:*",
"@lexical/rich-text": "workspace:*",
"lexical": "workspace:*"
},
"devDependencies": {
"@playwright/test": "^1.60.0",
"typescript": "^6.0.3",
"vite": "^8.0.16"
},
"optionalDependencies": {
"@rollup/rollup-linux-x64-gnu": "4.61.0",
"@rollup/rollup-darwin-arm64": "4.61.0",
"@rollup/rollup-win32-x64-msvc": "4.61.0",
"@rollup/wasm-node": "4.61.0"
}
}
24 changes: 24 additions & 0 deletions dev-examples/shadow-dom-web-component/playwright.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
*/

import type {PlaywrightTestConfig} from '@playwright/test';

const PORT = 4327;

const config: PlaywrightTestConfig = {
testDir: 'tests',
testMatch: /(.+\.)?(test|spec)\.[jt]s/,
use: {baseURL: `http://localhost:${PORT}`},
webServer: {
command: 'pnpm run dev',
port: PORT,
reuseExistingServer: true,
},
};

export default config;
Loading