From df284d647b5027eb0961a92283f88db287eb2b1b Mon Sep 17 00:00:00 2001 From: krassowski <5832902+krassowski@users.noreply.github.com> Date: Sat, 14 Jan 2023 23:22:33 +0000 Subject: [PATCH] Enable creating widgets in shadow DOM --- docs/source/migration.md | 9 +++ packages/widgets/src/docklayout.ts | 8 +-- packages/widgets/src/panellayout.ts | 8 +-- packages/widgets/src/widget.ts | 74 +++++++++++++++++++++-- packages/widgets/tests/src/widget.spec.ts | 55 +++++++++++++++++ review/api/widgets.api.md | 4 ++ 6 files changed, 146 insertions(+), 12 deletions(-) diff --git a/docs/source/migration.md b/docs/source/migration.md index fbd869b7d..8453f7cff 100644 --- a/docs/source/migration.md +++ b/docs/source/migration.md @@ -89,6 +89,15 @@ while (!(it = it.next()).done) { } ``` +### Replace `widget.node` with `widget.attachmentNode` + +Lumino 2 distinguishes between the contents node (`node`) and attachment node +(`attachmentNode`) of widgets to enable attaching widgets via shadow DOM root. +By default attachment and contents node are the same, but for widgets with +shadow DOM enabled, they differ. Downstream layouts need to update methods +attaching and detaching widgets to use attachment node if they want to support +moving the widgets to shadow DOM. + ## Public API changes ### `@lumino/algorithm` diff --git a/packages/widgets/src/docklayout.ts b/packages/widgets/src/docklayout.ts index 895ac6bab..fb33454bb 100644 --- a/packages/widgets/src/docklayout.ts +++ b/packages/widgets/src/docklayout.ts @@ -533,7 +533,7 @@ export class DockLayout extends Layout { */ protected attachWidget(widget: Widget): void { // Do nothing if the widget is already attached. - if (this.parent!.node === widget.node.parentNode) { + if (this.parent!.node === widget.attachmentNode.parentNode) { return; } @@ -546,7 +546,7 @@ export class DockLayout extends Layout { } // Add the widget's node to the parent. - this.parent!.node.appendChild(widget.node); + this.parent!.node.appendChild(widget.attachmentNode); // Send an `'after-attach'` message if the parent is attached. if (this.parent!.isAttached) { @@ -564,7 +564,7 @@ export class DockLayout extends Layout { */ protected detachWidget(widget: Widget): void { // Do nothing if the widget is not attached. - if (this.parent!.node !== widget.node.parentNode) { + if (this.parent!.node !== widget.attachmentNode.parentNode) { return; } @@ -574,7 +574,7 @@ export class DockLayout extends Layout { } // Remove the widget's node from the parent. - this.parent!.node.removeChild(widget.node); + this.parent!.node.removeChild(widget.attachmentNode); // Send an `'after-detach'` message if the parent is attached. if (this.parent!.isAttached) { diff --git a/packages/widgets/src/panellayout.ts b/packages/widgets/src/panellayout.ts index dc4891f28..9a9fadb68 100644 --- a/packages/widgets/src/panellayout.ts +++ b/packages/widgets/src/panellayout.ts @@ -212,7 +212,7 @@ export class PanelLayout extends Layout { } // Insert the widget's node before the sibling. - this.parent!.node.insertBefore(widget.node, ref); + this.parent!.node.insertBefore(widget.attachmentNode, ref); // Send an `'after-attach'` message if the parent is attached. if (this.parent!.isAttached) { @@ -251,7 +251,7 @@ export class PanelLayout extends Layout { } // Remove the widget's node from the parent. - this.parent!.node.removeChild(widget.node); + this.parent!.node.removeChild(widget.attachmentNode); // Send an `'after-detach'` and message if the parent is attached. if (this.parent!.isAttached) { @@ -267,7 +267,7 @@ export class PanelLayout extends Layout { } // Insert the widget's node before the sibling. - this.parent!.node.insertBefore(widget.node, ref); + this.parent!.node.insertBefore(widget.attachmentNode, ref); // Send an `'after-attach'` message if the parent is attached. if (this.parent!.isAttached) { @@ -300,7 +300,7 @@ export class PanelLayout extends Layout { } // Remove the widget's node from the parent. - this.parent!.node.removeChild(widget.node); + this.parent!.node.removeChild(widget.attachmentNode); // Send an `'after-detach'` message if the parent is attached. if (this.parent!.isAttached) { diff --git a/packages/widgets/src/widget.ts b/packages/widgets/src/widget.ts index ad97dce9c..06384cfb1 100644 --- a/packages/widgets/src/widget.ts +++ b/packages/widgets/src/widget.ts @@ -41,6 +41,15 @@ export class Widget implements IMessageHandler, IObservableDisposable { */ constructor(options: Widget.IOptions = {}) { this.node = Private.createNode(options); + if (options.shadowDOM) { + const attachmentNode = document.createElement('div'); + const root = attachmentNode.attachShadow({ mode: 'open' }); + root.appendChild(this.node); + attachmentNode.classList.add('lm-attachmentNode'); + this.attachmentNode = attachmentNode; + } else { + this.attachmentNode = this.node; + } this.addClass('lm-Widget'); } @@ -96,6 +105,11 @@ export class Widget implements IMessageHandler, IObservableDisposable { */ readonly node: HTMLElement; + /** + * Get the node which should be attached to the parent in order to attach the widget. + */ + readonly attachmentNode: HTMLElement; + /** * Test whether the widget has been disposed. */ @@ -367,6 +381,50 @@ export class Widget implements IMessageHandler, IObservableDisposable { return this.node.classList.toggle(name); } + /** + * Adopt style sheet to shadow root if present. + * + * Provided sheet must be programmatically created using + * the `CSSStyleSheet()` constructor. + * Has no effect if the sheet was already adopted. + * + * Returns `true` if sheet was adopted and `false` otherwise. + */ + adoptStyleSheet(sheet: CSSStyleSheet): boolean { + const root = this.attachmentNode.shadowRoot; + if (!root) { + throw new Error('Widget without shadowRoot cannot adopt sheets.'); + } + const alreadyAdopted = root.adoptedStyleSheets; + if (alreadyAdopted.indexOf(sheet) === -1) { + // Note: in-place mutations like `push()` are not allowed according to MDN + root.adoptedStyleSheets = [...root.adoptedStyleSheets, sheet]; + return true; + } + return false; + } + + /** + * Remove previously adopted style sheet from shadow root. + * + * Returns `true` if sheet was removed and `false` otherwise. + */ + removeAdoptedStyleSheet(sheet: CSSStyleSheet): boolean { + const root = this.attachmentNode.shadowRoot; + if (!root) { + throw new Error('Cannot remove sheet from widget without shadowRoot.'); + } + const alreadyAdopted = root.adoptedStyleSheets; + if (alreadyAdopted.indexOf(sheet) !== -1) { + // Note: in-place mutations like `slice()` are not allowed according to MDN + root.adoptedStyleSheets = root.adoptedStyleSheets.filter( + s => s !== sheet + ); + return true; + } + return false; + } + /** * Post an `'update-request'` message to the widget. * @@ -799,6 +857,13 @@ export namespace Widget { * value is ignored. */ tag?: keyof HTMLElementTagNameMap; + + /** + * Whether to embed the content node in shadow DOM. + * + * The default is `false`. + */ + shadowDOM?: boolean; } /** @@ -1086,14 +1151,15 @@ export namespace Widget { if (widget.parent) { throw new Error('Cannot attach a child widget.'); } - if (widget.isAttached || widget.node.isConnected) { + if (widget.isAttached || widget.attachmentNode.isConnected) { throw new Error('Widget is already attached.'); } if (!host.isConnected) { throw new Error('Host is not attached.'); } + MessageLoop.sendMessage(widget, Widget.Msg.BeforeAttach); - host.insertBefore(widget.node, ref); + host.insertBefore(widget.attachmentNode, ref); MessageLoop.sendMessage(widget, Widget.Msg.AfterAttach); } @@ -1110,11 +1176,11 @@ export namespace Widget { if (widget.parent) { throw new Error('Cannot detach a child widget.'); } - if (!widget.isAttached || !widget.node.isConnected) { + if (!widget.isAttached || !widget.attachmentNode.isConnected) { throw new Error('Widget is not attached.'); } MessageLoop.sendMessage(widget, Widget.Msg.BeforeDetach); - widget.node.parentNode!.removeChild(widget.node); + widget.node.parentNode!.removeChild(widget.attachmentNode); MessageLoop.sendMessage(widget, Widget.Msg.AfterDetach); } } diff --git a/packages/widgets/tests/src/widget.spec.ts b/packages/widgets/tests/src/widget.spec.ts index 765352a4a..9880b02e5 100644 --- a/packages/widgets/tests/src/widget.spec.ts +++ b/packages/widgets/tests/src/widget.spec.ts @@ -122,6 +122,16 @@ describe('@lumino/widgets', () => { let widget = new Widget(); expect(widget.hasClass('lm-Widget')).to.equal(true); }); + + it('should optionally proxy node via shadow DOM', () => { + let widget = new Widget({ shadowDOM: true }); + expect(widget.node).to.not.equal(widget.attachmentNode); + expect(widget.attachmentNode.shadowRoot).to.not.equal(null); + + widget = new Widget({ shadowDOM: false }); + expect(widget.node).to.equal(widget.attachmentNode); + expect(widget.attachmentNode.shadowRoot).to.equal(null); + }); }); describe('#dispose()', () => { @@ -557,6 +567,51 @@ describe('@lumino/widgets', () => { }); }); + describe('#adoptStyleSheet()', () => { + it('should adopt style sheets for widgets with shadow DOM', () => { + const sheet = new CSSStyleSheet(); + sheet.replaceSync('* { color: red; }'); + + let widget = new Widget({ shadowDOM: true }); + Widget.attach(widget, document.body); + + let div = document.createElement('div'); + widget.node.appendChild(div); + + expect(window.getComputedStyle(div).color).to.equal('rgb(0, 0, 0)'); + + let wasAdopted = widget.adoptStyleSheet(sheet); + expect(wasAdopted).to.equal(true); + expect(window.getComputedStyle(div).color).to.equal('rgb(255, 0, 0)'); + + wasAdopted = widget.adoptStyleSheet(sheet); + expect(wasAdopted).to.equal(false); + }); + }); + + describe('#removeAdoptedStyleSheet()', () => { + it('should adopt style sheets for widgets with shadow DOM', () => { + const sheet = new CSSStyleSheet(); + sheet.replaceSync('* { color: red; }'); + + let widget = new Widget({ shadowDOM: true }); + Widget.attach(widget, document.body); + + let div = document.createElement('div'); + widget.node.appendChild(div); + + widget.adoptStyleSheet(sheet); + expect(window.getComputedStyle(div).color).to.equal('rgb(255, 0, 0)'); + + let wasRemoved = widget.removeAdoptedStyleSheet(sheet); + expect(wasRemoved).to.equal(true); + expect(window.getComputedStyle(div).color).to.equal('rgb(0, 0, 0)'); + + wasRemoved = widget.removeAdoptedStyleSheet(sheet); + expect(wasRemoved).to.equal(false); + }); + }); + describe('#update()', () => { it('should post an `update-request` message', done => { let widget = new LogWidget(); diff --git a/review/api/widgets.api.md b/review/api/widgets.api.md index fca3f1f82..780385a5d 100644 --- a/review/api/widgets.api.md +++ b/review/api/widgets.api.md @@ -1258,6 +1258,8 @@ export class Widget implements IMessageHandler, IObservableDisposable { constructor(options?: Widget.IOptions); activate(): void; addClass(name: string): void; + adoptStyleSheet(sheet: CSSStyleSheet): boolean; + readonly attachmentNode: HTMLElement; children(): IterableIterator; clearFlag(flag: Widget.Flag): void; close(): void; @@ -1298,6 +1300,7 @@ export class Widget implements IMessageHandler, IObservableDisposable { get parent(): Widget | null; set parent(value: Widget | null); processMessage(msg: Message): void; + removeAdoptedStyleSheet(sheet: CSSStyleSheet): boolean; removeClass(name: string): void; setFlag(flag: Widget.Flag): void; setHidden(hidden: boolean): void; @@ -1330,6 +1333,7 @@ export namespace Widget { } export interface IOptions { node?: HTMLElement; + shadowDOM?: boolean; tag?: keyof HTMLElementTagNameMap; } export namespace Msg {