diff --git a/packages/fast-element/docs/roadmap.md b/packages/fast-element/docs/roadmap.md index 839f0fcf131..815a2de4dac 100644 --- a/packages/fast-element/docs/roadmap.md +++ b/packages/fast-element/docs/roadmap.md @@ -2,8 +2,6 @@ ## Short-term -* **Fix**: Improve subscription cleanup on complex observable expressions. -* **Feature**: ElementStyle Registration and Behavior Association. * **Feature**: Support `class` and/or `style` bindings on the host. * **Test**: Testing infrastructure and test coverage. diff --git a/packages/fast-element/src/controller.ts b/packages/fast-element/src/controller.ts index 2b4660a6935..dbc4578de7d 100644 --- a/packages/fast-element/src/controller.ts +++ b/packages/fast-element/src/controller.ts @@ -3,6 +3,8 @@ import { Container, InterfaceSymbol, Registry, Resolver } from "./di"; import { ElementView } from "./view"; import { PropertyChangeNotifier } from "./observation/notifier"; import { Observable } from "./observation/observable"; +import { Behavior } from "./directives/behavior"; +import { ElementStyles, StyleTarget } from "./styles"; const defaultEventOptions: CustomEventInit = { bubbles: true, @@ -14,6 +16,7 @@ export class Controller extends PropertyChangeNotifier implements Container { public isConnected: boolean = false; private resolvers: Map = new Map(); private boundObservables: Record | null = null; + private behaviors: Behavior[] | null = null; public constructor( public readonly element: HTMLElement, @@ -38,8 +41,8 @@ export class Controller extends PropertyChangeNotifier implements Container { } } - if (styles !== void 0 && shadowRoot !== void 0) { - styles.applyTo(shadowRoot); + if (styles !== void 0) { + this.addStyles(styles, shadowRoot); } definition.dependencies.forEach((x: Registry) => x.register(this)); @@ -65,6 +68,78 @@ export class Controller extends PropertyChangeNotifier implements Container { } } + public addStyles( + styles: ElementStyles, + target: StyleTarget | null = this.element.shadowRoot + ): void { + if (target !== null) { + styles.addStylesTo(target); + } + + const sourceBehaviors = styles.behaviors; + + if (sourceBehaviors !== null) { + this.addBehaviors(sourceBehaviors); + } + } + + public removeStyles(styles: ElementStyles): void { + const target = this.element.shadowRoot; + + if (target !== null) { + styles.removeStylesFrom(target); + } + + const sourceBehaviors = styles.behaviors; + + if (sourceBehaviors !== null) { + this.removeBehaviors(sourceBehaviors); + } + } + + public addBehaviors(behaviors: ReadonlyArray): void { + const targetBehaviors = this.behaviors || (this.behaviors = []); + const length = behaviors.length; + + for (let i = 0; i < length; ++i) { + targetBehaviors.push(behaviors[i]); + } + + if (this.isConnected) { + const element = this.element; + + for (let i = 0; i < length; ++i) { + behaviors[i].bind(element); + } + } + } + + public removeBehaviors(behaviors: ReadonlyArray): void { + const targetBehaviors = this.behaviors; + + if (targetBehaviors === null) { + return; + } + + const length = behaviors.length; + + for (let i = 0; i < length; ++i) { + const index = targetBehaviors.indexOf(behaviors[i]); + + if (index !== -1) { + targetBehaviors.splice(index, 1); + } + } + + if (this.isConnected) { + const element = this.element; + + for (let i = 0; i < length; ++i) { + behaviors[i].unbind(element); + } + } + } + public onConnectedCallback(): void { if (this.isConnected) { return; @@ -85,8 +160,18 @@ export class Controller extends PropertyChangeNotifier implements Container { this.boundObservables = null; } - if (this.view !== null) { - this.view.bind(element); + const view = this.view; + + if (view !== null) { + view.bind(element); + } + + const behaviors = this.behaviors; + + if (behaviors !== null) { + for (let i = 0, ii = behaviors.length; i < ii; ++i) { + behaviors[i].bind(element); + } } this.isConnected = true; @@ -99,8 +184,20 @@ export class Controller extends PropertyChangeNotifier implements Container { this.isConnected = false; - if (this.view !== null) { - this.view.unbind(); + const view = this.view; + + if (view !== null) { + view.unbind(); + } + + const behaviors = this.behaviors; + + if (behaviors !== null) { + const element = this.element; + + for (let i = 0, ii = behaviors.length; i < ii; ++i) { + behaviors[i].unbind(element); + } } } diff --git a/packages/fast-element/src/directives/behavior.ts b/packages/fast-element/src/directives/behavior.ts index b39e6a51c98..0d543399aea 100644 --- a/packages/fast-element/src/directives/behavior.ts +++ b/packages/fast-element/src/directives/behavior.ts @@ -1,6 +1,6 @@ export interface Behavior { bind(source: unknown): void; - unbind(): void; + unbind(source: unknown): void; } export interface BehaviorFactory { diff --git a/packages/fast-element/src/observation/array-change-records.ts b/packages/fast-element/src/observation/array-change-records.ts index ef960fd4b89..bbaf3cb6bb7 100644 --- a/packages/fast-element/src/observation/array-change-records.ts +++ b/packages/fast-element/src/observation/array-change-records.ts @@ -208,7 +208,7 @@ export function calcSplices( old: any[], oldStart: number, oldEnd: number -): readonly never[] | Splice[] { +): ReadonlyArray | Splice[] { let prefixCount = 0; let suffixCount = 0; diff --git a/packages/fast-element/src/styles.ts b/packages/fast-element/src/styles.ts index c16dcf7622c..99ffbf9a1b1 100644 --- a/packages/fast-element/src/styles.ts +++ b/packages/fast-element/src/styles.ts @@ -1,35 +1,86 @@ -const elementStylesBrand = Symbol(); +import { Behavior } from "./directives/behavior"; -export interface ElementStyles { - readonly styles: InjectableStyles[]; - applyTo(shadowRoot: ShadowRoot): void; +export interface StyleTarget { + adoptedStyleSheets?: CSSStyleSheet[]; + + prepend(node: Node): void; + removeChild(node: Node): void; + querySelectorAll(selectors: string): NodeListOf; } -type InjectableStyles = string | ElementStyles; -type ElementStyleFactory = (styles: InjectableStyles[]) => ElementStyles; +const styleLookup = new Map(); + +export abstract class ElementStyles { + /** @internal */ + public abstract readonly styles: ReadonlyArray; + + /** @internal */ + public abstract readonly behaviors: ReadonlyArray | null = null; + + /** @internal */ + public abstract addStylesTo(target: StyleTarget): void; + + /** @internal */ + public abstract removeStylesFrom(target: StyleTarget): void; + + public withBehaviors(...behaviors: Behavior[]): this { + (this.behaviors as any) = + this.behaviors === null ? behaviors : this.behaviors.concat(behaviors); + + return this; + } -function isElementStyles(object: any): object is ElementStyles { - return object.brand === elementStylesBrand; + public withKey(key: string): this { + styleLookup.set(key, this); + return this; + } + + public static find(key: string): ElementStyles | null { + return styleLookup.get(key) || null; + } } -function reduceStyles(styles: InjectableStyles[]): string[] { +type InjectableStyles = string | ElementStyles; +type ElementStyleFactory = (styles: ReadonlyArray) => ElementStyles; + +function reduceStyles(styles: ReadonlyArray): string[] { return styles - .map((x: InjectableStyles) => (isElementStyles(x) ? reduceStyles(x.styles) : [x])) + .map((x: InjectableStyles) => + x instanceof ElementStyles ? reduceStyles(x.styles) : [x] + ) .reduce((prev: string[], curr: string[]) => prev.concat(curr), []); } -type HasAdoptedStyleSheets = ShadowRoot & { - adoptedStyleSheets: CSSStyleSheet[]; -}; +function reduceBehaviors( + styles: ReadonlyArray +): ReadonlyArray | null { + return styles + .map((x: InjectableStyles) => (x instanceof ElementStyles ? x.behaviors : null)) + .reduce((prev: Behavior[] | null, curr: Behavior[] | null) => { + if (curr === null) { + return prev; + } + + if (prev === null) { + prev = []; + } + + return prev.concat(curr); + }, null as Behavior[] | null); +} -export class AdoptedStyleSheetsStyles implements ElementStyles { - public readonly brand: symbol = elementStylesBrand; +// https://wicg.github.io/construct-stylesheets/ +// https://developers.google.com/web/updates/2019/02/constructable-stylesheets +export class AdoptedStyleSheetsStyles extends ElementStyles { private readonly styleSheets: CSSStyleSheet[]; + public readonly behaviors: ReadonlyArray | null = null; public constructor( public styles: InjectableStyles[], styleSheetCache: Map ) { + super(); + this.behaviors = reduceBehaviors(styles); this.styleSheets = reduceStyles(styles).map((x: string) => { let sheet = styleSheetCache.get(x); @@ -43,31 +94,54 @@ export class AdoptedStyleSheetsStyles implements ElementStyles { }); } - public applyTo(shadowRoot: HasAdoptedStyleSheets): void { - // https://wicg.github.io/construct-stylesheets/ - // https://developers.google.com/web/updates/2019/02/constructable-stylesheets - shadowRoot.adoptedStyleSheets = [ - ...shadowRoot.adoptedStyleSheets, - ...this.styleSheets, - ]; + public addStylesTo(target: StyleTarget): void { + target.adoptedStyleSheets = [...target.adoptedStyleSheets!, ...this.styleSheets]; + } + + public removeStylesFrom(target: StyleTarget): void { + const sourceSheets = this.styleSheets; + target.adoptedStyleSheets = target.adoptedStyleSheets!.filter( + (x: CSSStyleSheet) => !sourceSheets.includes(x) + ); } } -export class StyleElementStyles implements ElementStyles { - public readonly brand: symbol = elementStylesBrand; +let styleClassId = 0; + +function getNextStyleClass(): string { + return `fast-style-class-${++styleClassId}`; +} + +export class StyleElementStyles extends ElementStyles { + public readonly behaviors: ReadonlyArray | null = null; + private styleSheets: string[]; + private styleClass: string; public constructor(public styles: InjectableStyles[]) { + super(); + this.behaviors = reduceBehaviors(styles); this.styleSheets = reduceStyles(styles); + this.styleClass = getNextStyleClass(); } - public applyTo(shadowRoot: ShadowRoot): void { + public addStylesTo(target: StyleTarget): void { const styleSheets = this.styleSheets; + const styleClass = this.styleClass; for (let i = styleSheets.length - 1; i > -1; --i) { const element = document.createElement("style"); element.innerHTML = styleSheets[i]; - shadowRoot.prepend(element); + element.className = styleClass; + target.prepend(element); + } + } + + public removeStylesFrom(target: StyleTarget): void { + const styles = target.querySelectorAll(`.${this.styleClass}`); + + for (const style of styles) { + target.removeChild(style); } } } @@ -95,7 +169,12 @@ export function css( cssString += strings[i]; const value = values[i]; - if (isElementStyles(value)) { + if (value instanceof ElementStyles) { + if (cssString.trim() !== "") { + styles.push(cssString); + cssString = ""; + } + styles.push(value); } else { cssString += value; @@ -103,7 +182,10 @@ export function css( } cssString += strings[strings.length - 1]; - styles.push(cssString); + + if (cssString.trim() !== "") { + styles.push(cssString); + } return createStyles(styles); } diff --git a/packages/fast-element/src/view.ts b/packages/fast-element/src/view.ts index 7d380e22bfb..0caaf5c5cd6 100644 --- a/packages/fast-element/src/view.ts +++ b/packages/fast-element/src/view.ts @@ -147,9 +147,10 @@ export class HTMLView implements ElementView, SyntheticView { parent.removeChild(end); const behaviors = this.behaviors; + const oldSource = this.source; for (let i = 0, ii = behaviors.length; i < ii; ++i) { - behaviors[i].unbind(); + behaviors[i].unbind(oldSource); } } @@ -158,16 +159,22 @@ export class HTMLView implements ElementView, SyntheticView { * @param source The binding source for the view's binding behaviors. */ public bind(source: unknown): void { + const behaviors = this.behaviors; + if (this.source === source) { return; } else if (this.source !== void 0) { - this.unbind(); - } - - const behaviors = this.behaviors; + const oldSource = this.source; - for (let i = 0, ii = behaviors.length; i < ii; ++i) { - behaviors[i].bind(source); + for (let i = 0, ii = behaviors.length; i < ii; ++i) { + const current = behaviors[i]; + current.unbind(oldSource); + current.bind(source); + } + } else { + for (let i = 0, ii = behaviors.length; i < ii; ++i) { + behaviors[i].bind(source); + } } } @@ -180,9 +187,10 @@ export class HTMLView implements ElementView, SyntheticView { } const behaviors = this.behaviors; + const oldSource = this.source; for (let i = 0, ii = behaviors.length; i < ii; ++i) { - behaviors[i].unbind(); + behaviors[i].unbind(oldSource); } this.source = void 0; @@ -202,10 +210,12 @@ export class HTMLView implements ElementView, SyntheticView { range.deleteContents(); for (let i = 0, ii = views.length; i < ii; ++i) { - const behaviors = (views[i] as any).behaviors as Behavior[]; + const view = views[i] as any; + const behaviors = view.behaviors as Behavior[]; + const oldSource = view.source; for (let j = 0, jj = behaviors.length; j < jj; ++j) { - behaviors[j].unbind(); + behaviors[j].unbind(oldSource); } } }