Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Enable creating widgets in shadow DOM #517

Draft
wants to merge 1 commit into
base: main
Choose a base branch
from
Draft
Changes from all commits
Commits
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
9 changes: 9 additions & 0 deletions docs/source/migration.md
Original file line number Diff line number Diff line change
@@ -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`
8 changes: 4 additions & 4 deletions packages/widgets/src/docklayout.ts
Original file line number Diff line number Diff line change
@@ -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) {
8 changes: 4 additions & 4 deletions packages/widgets/src/panellayout.ts
Original file line number Diff line number Diff line change
@@ -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) {
74 changes: 70 additions & 4 deletions packages/widgets/src/widget.ts
Original file line number Diff line number Diff line change
@@ -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);
}
}
55 changes: 55 additions & 0 deletions packages/widgets/tests/src/widget.spec.ts
Original file line number Diff line number Diff line change
@@ -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();
4 changes: 4 additions & 0 deletions review/api/widgets.api.md
Original file line number Diff line number Diff line change
@@ -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<Widget>;
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 {