From b3fb604b3f6ec31ca739b1bc3345d66902b8ea6d Mon Sep 17 00:00:00 2001 From: Marshal Thompson Date: Mon, 10 Oct 2022 08:49:10 -0500 Subject: [PATCH] feat: Shadow DOM --- src/engine.ts | 142 ++++++++++++++++++++++++++++++++++++++++++++++- src/globals.d.ts | 7 +++ 2 files changed, 147 insertions(+), 2 deletions(-) diff --git a/src/engine.ts b/src/engine.ts index f13d078..276dda5 100644 --- a/src/engine.ts +++ b/src/engine.ts @@ -79,6 +79,7 @@ type LayoutState = { const INSTANCE_SYMBOL: unique symbol = Symbol('CQ_INSTANCE'); const STYLESHEET_SYMBOL: unique symbol = Symbol('CQ_STYLESHEET'); +const SHADOW_SYMBOL: unique symbol = Symbol('CQ_SHADOW'); const SUPPORTS_SMALL_VIEWPORT_UNITS = CSS.supports('width: 1svh'); const VERTICAL_WRITING_MODES = new Set([ 'vertical-lr', @@ -179,7 +180,7 @@ export function initializePolyfill() { const dummyElement = document.createElement(`cq-polyfill-${PER_RUN_UID}`); const globalStyleElement = document.createElement('style'); - const mutationObserver = new MutationObserver(mutations => { + const createMutationObserver = (): MutationObserver => new MutationObserver(mutations => { for (const entry of mutations) { for (const node of entry.removedNodes) { const instance = getInstance(node); @@ -210,6 +211,7 @@ export function initializePolyfill() { scheduleUpdate(); } }); + const mutationObserver = createMutationObserver(); mutationObserver.observe(documentElement, { childList: true, subtree: true, @@ -217,6 +219,42 @@ export function initializePolyfill() { attributeOldValue: true, }); + const originalAttachShadow = Element.prototype.attachShadow; + Element.prototype.attachShadow = function (options) { + const shadow = originalAttachShadow.apply(this, [options]); + getOrCreateInstance(shadow); + return shadow; + } + + const originalReplaceSync = CSSStyleSheet.prototype.replaceSync; + if (originalReplaceSync) { + CSSStyleSheet.prototype.replaceSync = function (options) { + const result = transpileStyleSheet( + options, + undefined + ); + setDescriptorsForStyleSheet(this, result.descriptors); + const replaceSync = originalReplaceSync.apply(this, [result.source]); + return replaceSync; + } + } + + const originalReplace = CSSStyleSheet.prototype.replace; + if (originalReplace) { + CSSStyleSheet.prototype.replace = function (options) { + const result = transpileStyleSheet( + options, + undefined + ); + setDescriptorsForStyleSheet(this, result.descriptors); + const replace = originalReplace.apply(this, [result.source]); + return replace; + } + } + + // TODO: implement monkeypatch for CSSStyleSheet.prototype.insertRule and CSSStyleSheet.prototype.deleteRule + // as they currently are not handled. + const resizeObserver = new ResizeObserver(entries => { for (const entry of entries) { const instance = getOrCreateInstance(entry.target); @@ -360,7 +398,7 @@ export function initializePolyfill() { function getOrCreateInstance(node: Node): Instance { let instance = getInstance(node); if (!instance) { - let innerController: NodeController; + let innerController: NodeController; let stateProvider: LayoutStateProvider | null = null; let alwaysObserveSize = false; @@ -381,6 +419,8 @@ export function initializePolyfill() { ...options, }), }); + } else if (node instanceof ShadowRoot) { + innerController = new ShadowRootController(node, createMutationObserver()); } else if (node instanceof HTMLStyleElement) { innerController = new StyleElementController(node, { registerStyleSheet: options => @@ -416,6 +456,26 @@ export function initializePolyfill() { ]; }; + const updateShadowHostState: ( + node: ShadowRoot, + state: LayoutState + ) => void = (node, state) => { + const rootQueryDescriptors: ContainerConditionEntry[] = []; + for (const adoptedStyleSheet of node.adoptedStyleSheets) { + if (adoptedStyleSheet) { + for (const query of getDescriptorsForStyleSheet(adoptedStyleSheet)) { + rootQueryDescriptors.push([ + new Reference(query), + QueryContainerFlags.None, + ]); + } + } + } + if (rootQueryDescriptors.length) { + state.conditions = rootQueryDescriptors; + } + }; + const inlineStyles = node instanceof HTMLElement || node instanceof SVGElement ? node.style @@ -517,6 +577,12 @@ export function initializePolyfill() { for (const child of node.childNodes) { getOrCreateInstance(child).update(currentState); } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const shadow = (node as any)[SHADOW_SYMBOL]; + if (shadow) { + updateShadowHostState(shadow, currentState); + getOrCreateInstance(shadow).update(currentState); + } }, resize() { @@ -525,9 +591,15 @@ export function initializePolyfill() { mutate() { cacheKey = Symbol(); + innerController.mutated(); for (const child of node.childNodes) { getOrCreateInstance(child).mutate(); } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const shadow = (node as any)[SHADOW_SYMBOL]; + if (shadow) { + getOrCreateInstance(shadow).mutate(); + } }, }; @@ -560,6 +632,10 @@ class NodeController { updated() { // Handler implemented by subclasses } + + mutated() { + // Handler implemented by subclasses + } } class LinkElementController extends NodeController { @@ -644,6 +720,68 @@ class StyleElementController extends NodeController { } } +class ShadowRootController extends NodeController { + private controller: AbortController | null = null; + private mo: MutationObserver; + private host: HTMLElement; + + constructor(node: ShadowRoot, mo: MutationObserver) { + super(node); + this.mo = mo; + this.host = node.host as HTMLElement; + } + + connected(): void { + this.mo?.observe(this.node, { + childList: true, + subtree: true, + attributes: true, + attributeOldValue: true, + }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (this.host as any)[SHADOW_SYMBOL] = this.node; + } + + updated() { + this.processCssRules(); + } + + mutated() { + this.processCssRules(); + } + + disconnected(): void { + this.controller?.abort(); + this.controller = null; + this.mo?.disconnect(); + } + + private processCssRules(): void { + let setProp = false; + for (const adoptedStyleSheet of this.node.adoptedStyleSheets) { + for (const rule of adoptedStyleSheet.cssRules) { + if (rule instanceof CSSStyleRule) { + const value = rule.style.getPropertyValue(CUSTOM_PROPERTY_TYPE).trim(); + if (value) { + const selector: string = rule.selectorText.replace(':host', this.host.localName); + // handle parentheses on :host([attribute]) + const mutatedSelector = !selector.includes(':') ? selector.replace('(', '').replace(')', '') : selector; + // if match apply rule + if (this.host.matches(mutatedSelector)) { + this.host.style.setProperty(CUSTOM_PROPERTY_TYPE, value); + setProp = true; + break; + } + } + } + } + } + if (!setProp && this.host.style.getPropertyValue(CUSTOM_PROPERTY_TYPE)) { + this.host.style.removeProperty(CUSTOM_PROPERTY_TYPE); + } + } +} + class GlobalStyleElementController extends NodeController { connected(): void { const style = `* { ${CUSTOM_PROPERTY_TYPE}: cq-normal; ${CUSTOM_PROPERTY_NAME}: cq-none; }`; diff --git a/src/globals.d.ts b/src/globals.d.ts index 9fed1c5..2844291 100644 --- a/src/globals.d.ts +++ b/src/globals.d.ts @@ -13,3 +13,10 @@ declare const IS_WPT_BUILD: boolean; declare const PACKAGE_VERSION: string; +interface ShadowRoot { + adoptedStyleSheets: CSSStyleSheet[]; +} +interface StyleSheet { + replace(text: string): Promise; + replaceSync(text: string): void; +}