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
Show file tree
Hide file tree
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
Expand Up @@ -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`
Expand Down
8 changes: 4 additions & 4 deletions packages/widgets/src/docklayout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand All @@ -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) {
Expand All @@ -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;
}

Expand All @@ -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) {
Expand Down
8 changes: 4 additions & 4 deletions packages/widgets/src/panellayout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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) {
Expand All @@ -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) {
Expand Down Expand Up @@ -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) {
Expand Down
74 changes: 70 additions & 4 deletions packages/widgets/src/widget.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
}

Expand Down Expand Up @@ -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.
*/
Expand Down Expand Up @@ -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.
*
Expand Down Expand Up @@ -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;
}

/**
Expand Down Expand Up @@ -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);
}

Expand All @@ -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);
}
}
Expand Down
55 changes: 55 additions & 0 deletions packages/widgets/tests/src/widget.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()', () => {
Expand Down Expand Up @@ -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();
Expand Down
4 changes: 4 additions & 0 deletions review/api/widgets.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -1330,6 +1333,7 @@ export namespace Widget {
}
export interface IOptions {
node?: HTMLElement;
shadowDOM?: boolean;
tag?: keyof HTMLElementTagNameMap;
}
export namespace Msg {
Expand Down