diff --git a/src/core/outlet_observer.ts b/src/core/outlet_observer.ts index 0208129f..b0956f47 100644 --- a/src/core/outlet_observer.ts +++ b/src/core/outlet_observer.ts @@ -1,64 +1,79 @@ import { Multimap } from "../multimap" +import { AttributeObserver, AttributeObserverDelegate } from "../mutation-observers" import { SelectorObserver, SelectorObserverDelegate } from "../mutation-observers" import { Context } from "./context" import { Controller } from "./controller" import { readInheritableStaticArrayValues } from "./inheritable_statics" -type SelectorObserverDetails = { outletName: string } +type OutletObserverDetails = { outletName: string } export interface OutletObserverDelegate { outletConnected(outlet: Controller, element: Element, outletName: string): void outletDisconnected(outlet: Controller, element: Element, outletName: string): void } -export class OutletObserver implements SelectorObserverDelegate { +export class OutletObserver implements AttributeObserverDelegate, SelectorObserverDelegate { + started: boolean readonly context: Context readonly delegate: OutletObserverDelegate readonly outletsByName: Multimap readonly outletElementsByName: Multimap private selectorObserverMap: Map + private attributeObserverMap: Map constructor(context: Context, delegate: OutletObserverDelegate) { + this.started = false this.context = context this.delegate = delegate this.outletsByName = new Multimap() this.outletElementsByName = new Multimap() this.selectorObserverMap = new Map() + this.attributeObserverMap = new Map() } start() { - if (this.selectorObserverMap.size === 0) { + if (!this.started) { this.outletDefinitions.forEach((outletName) => { - const selector = this.selector(outletName) - const details: SelectorObserverDetails = { outletName } - - if (selector) { - this.selectorObserverMap.set(outletName, new SelectorObserver(document.body, selector, this, details)) - } + this.setupSelectorObserverForOutlet(outletName) + this.setupAttributeObserverForOutlet(outletName) }) - - this.selectorObserverMap.forEach((observer) => observer.start()) + this.started = true + this.dependentContexts.forEach((context) => context.refresh()) } + } - this.dependentContexts.forEach((context) => context.refresh()) + refresh() { + this.selectorObserverMap.forEach((observer) => observer.refresh()) + this.attributeObserverMap.forEach((observer) => observer.refresh()) } stop() { - if (this.selectorObserverMap.size > 0) { + if (this.started) { + this.started = false this.disconnectAllOutlets() + this.stopSelectorObservers() + this.stopAttributeObservers() + } + } + + stopSelectorObservers() { + if (this.selectorObserverMap.size > 0) { this.selectorObserverMap.forEach((observer) => observer.stop()) this.selectorObserverMap.clear() } } - refresh() { - this.selectorObserverMap.forEach((observer) => observer.refresh()) + stopAttributeObservers() { + if (this.attributeObserverMap.size > 0) { + this.attributeObserverMap.forEach((observer) => observer.stop()) + this.attributeObserverMap.clear() + } } // Selector observer delegate - selectorMatched(element: Element, _selector: string, { outletName }: SelectorObserverDetails) { + selectorMatched(element: Element, _selector: string, { outletName }: OutletObserverDetails) { const outlet = this.getOutlet(element, outletName) if (outlet) { @@ -66,7 +81,7 @@ export class OutletObserver implements SelectorObserverDelegate { } } - selectorUnmatched(element: Element, _selector: string, { outletName }: SelectorObserverDetails) { + selectorUnmatched(element: Element, _selector: string, { outletName }: OutletObserverDetails) { const outlet = this.getOutletFromMap(element, outletName) if (outlet) { @@ -74,11 +89,42 @@ export class OutletObserver implements SelectorObserverDelegate { } } - selectorMatchElement(element: Element, { outletName }: SelectorObserverDetails) { - return ( - this.hasOutlet(element, outletName) && - element.matches(`[${this.context.application.schema.controllerAttribute}~=${outletName}]`) - ) + selectorMatchElement(element: Element, { outletName }: OutletObserverDetails) { + const selector = this.selector(outletName) + const hasOutlet = this.hasOutlet(element, outletName) + const hasOutletController = element.matches(`[${this.schema.controllerAttribute}~=${outletName}]`) + + if (selector) { + return hasOutlet && hasOutletController && element.matches(selector) + } else { + return false + } + } + + // Attribute observer delegate + + elementMatchedAttribute(_element: Element, attributeName: string) { + const outletName = this.getOutletNameFromOutletAttributeName(attributeName) + + if (outletName) { + this.updateSelectorObserverForOutlet(outletName) + } + } + + elementAttributeValueChanged(_element: Element, attributeName: string) { + const outletName = this.getOutletNameFromOutletAttributeName(attributeName) + + if (outletName) { + this.updateSelectorObserverForOutlet(outletName) + } + } + + elementUnmatchedAttribute(_element: Element, attributeName: string) { + const outletName = this.getOutletNameFromOutletAttributeName(attributeName) + + if (outletName) { + this.updateSelectorObserverForOutlet(outletName) + } } // Outlet management @@ -111,12 +157,48 @@ export class OutletObserver implements SelectorObserverDelegate { } } + // Observer management + + private updateSelectorObserverForOutlet(outletName: string) { + const observer = this.selectorObserverMap.get(outletName) + + if (observer) { + observer.selector = this.selector(outletName) + } + } + + private setupSelectorObserverForOutlet(outletName: string) { + const selector = this.selector(outletName) + const selectorObserver = new SelectorObserver(document.body, selector!, this, { outletName }) + + this.selectorObserverMap.set(outletName, selectorObserver) + + selectorObserver.start() + } + + private setupAttributeObserverForOutlet(outletName: string) { + const attributeName = this.attributeNameForOutletName(outletName) + const attributeObserver = new AttributeObserver(this.scope.element, attributeName, this) + + this.attributeObserverMap.set(outletName, attributeObserver) + + attributeObserver.start() + } + // Private private selector(outletName: string) { return this.scope.outlets.getSelectorForOutletName(outletName) } + private attributeNameForOutletName(outletName: string) { + return this.scope.schema.outletAttributeForScope(this.identifier, outletName) + } + + private getOutletNameFromOutletAttributeName(attributeName: string) { + return this.outletDefinitions.find((outletName) => this.attributeNameForOutletName(outletName) === attributeName) + } + private get outletDependencies() { const dependencies = new Multimap() @@ -159,6 +241,10 @@ export class OutletObserver implements SelectorObserverDelegate { return this.context.scope } + private get schema() { + return this.context.schema + } + private get identifier() { return this.context.identifier } diff --git a/src/mutation-observers/selector_observer.ts b/src/mutation-observers/selector_observer.ts index d18d09ec..6321de64 100644 --- a/src/mutation-observers/selector_observer.ts +++ b/src/mutation-observers/selector_observer.ts @@ -8,14 +8,14 @@ export interface SelectorObserverDelegate { } export class SelectorObserver implements ElementObserverDelegate { - private selector: string - private elementObserver: ElementObserver - private delegate: SelectorObserverDelegate - private matchesByElement: Multimap - private details: object - - constructor(element: Element, selector: string, delegate: SelectorObserverDelegate, details: object = {}) { - this.selector = selector + private readonly elementObserver: ElementObserver + private readonly delegate: SelectorObserverDelegate + private readonly matchesByElement: Multimap + private readonly details: object + _selector: string | null + + constructor(element: Element, selector: string, delegate: SelectorObserverDelegate, details: object) { + this._selector = selector this.details = details this.elementObserver = new ElementObserver(element, this) this.delegate = delegate @@ -26,6 +26,15 @@ export class SelectorObserver implements ElementObserverDelegate { return this.elementObserver.started } + get selector() { + return this._selector + } + + set selector(selector: string | null) { + this._selector = selector + this.refresh() + } + start() { this.elementObserver.start() } @@ -49,47 +58,73 @@ export class SelectorObserver implements ElementObserverDelegate { // Element observer delegate matchElement(element: Element): boolean { - const matches = element.matches(this.selector) + const { selector } = this - if (this.delegate.selectorMatchElement) { - return matches && this.delegate.selectorMatchElement(element, this.details) - } + if (selector) { + const matches = element.matches(selector) + + if (this.delegate.selectorMatchElement) { + return matches && this.delegate.selectorMatchElement(element, this.details) + } - return matches + return matches + } else { + return false + } } matchElementsInTree(tree: Element): Element[] { - const match = this.matchElement(tree) ? [tree] : [] - const matches = Array.from(tree.querySelectorAll(this.selector)).filter((match) => this.matchElement(match)) - return match.concat(matches) + const { selector } = this + + if (selector) { + const match = this.matchElement(tree) ? [tree] : [] + const matches = Array.from(tree.querySelectorAll(selector)).filter((match) => this.matchElement(match)) + return match.concat(matches) + } else { + return [] + } } elementMatched(element: Element) { - this.selectorMatched(element) + const { selector } = this + + if (selector) { + this.selectorMatched(element, selector) + } } elementUnmatched(element: Element) { - this.selectorUnmatched(element) + const selectors = this.matchesByElement.getKeysForValue(element) + + for (const selector of selectors) { + this.selectorUnmatched(element, selector) + } } elementAttributeChanged(element: Element, _attributeName: string) { - const matches = this.matchElement(element) - const matchedBefore = this.matchesByElement.has(this.selector, element) + const { selector } = this - if (!matches && matchedBefore) { - this.selectorUnmatched(element) + if (selector) { + const matches = this.matchElement(element) + const matchedBefore = this.matchesByElement.has(selector, element) + + if (matches && !matchedBefore) { + this.selectorMatched(element, selector) + } else if (!matches && matchedBefore) { + this.selectorUnmatched(element, selector) + } } } - private selectorMatched(element: Element) { - if (this.delegate.selectorMatched) { - this.delegate.selectorMatched(element, this.selector, this.details) - this.matchesByElement.add(this.selector, element) - } + // Selector management + + private selectorMatched(element: Element, selector: string) { + this.delegate.selectorMatched(element, selector, this.details) + this.matchesByElement.add(selector, element) } - private selectorUnmatched(element: Element) { - this.delegate.selectorUnmatched(element, this.selector, this.details) - this.matchesByElement.delete(this.selector, element) + private selectorUnmatched(element: Element, selector: string) { + this.delegate.selectorUnmatched(element, selector, this.details) + this.matchesByElement.delete(selector, element) } } diff --git a/src/tests/cases/dom_test_case.ts b/src/tests/cases/dom_test_case.ts index 438239ef..b028b2f7 100644 --- a/src/tests/cases/dom_test_case.ts +++ b/src/tests/cases/dom_test_case.ts @@ -60,6 +60,34 @@ export class DOMTestCase extends TestCase { return event } + async setAttribute(selectorOrElement: string | Element, name: string, value: string) { + const element = typeof selectorOrElement == "string" ? this.findElement(selectorOrElement) : selectorOrElement + + element.setAttribute(name, value) + await this.nextFrame + } + + async removeAttribute(selectorOrElement: string | Element, name: string) { + const element = typeof selectorOrElement == "string" ? this.findElement(selectorOrElement) : selectorOrElement + + element.removeAttribute(name) + await this.nextFrame + } + + async appendChild(selectorOrElement: T | string, child: T) { + const parent = typeof selectorOrElement == "string" ? this.findElement(selectorOrElement) : selectorOrElement + + parent.appendChild(child) + await this.nextFrame + } + + async remove(selectorOrElement: Element | string) { + const element = typeof selectorOrElement == "string" ? this.findElement(selectorOrElement) : selectorOrElement + + element.remove() + await this.nextFrame + } + findElement(selector: string) { const element = this.fixtureElement.querySelector(selector) if (element) { diff --git a/src/tests/controllers/outlet_controller.ts b/src/tests/controllers/outlet_controller.ts index c3a5b840..261a4734 100644 --- a/src/tests/controllers/outlet_controller.ts +++ b/src/tests/controllers/outlet_controller.ts @@ -19,6 +19,8 @@ export class OutletController extends BaseOutletController { alphaOutletDisconnectedCallCount: Number, betaOutletConnectedCallCount: Number, betaOutletDisconnectedCallCount: Number, + gammaOutletConnectedCallCount: Number, + gammaOutletDisconnectedCallCount: Number, namespacedEpsilonOutletConnectedCallCount: Number, namespacedEpsilonOutletDisconnectedCallCount: Number, } @@ -44,6 +46,8 @@ export class OutletController extends BaseOutletController { alphaOutletDisconnectedCallCountValue = 0 betaOutletConnectedCallCountValue = 0 betaOutletDisconnectedCallCountValue = 0 + gammaOutletConnectedCallCountValue = 0 + gammaOutletDisconnectedCallCountValue = 0 namespacedEpsilonOutletConnectedCallCountValue = 0 namespacedEpsilonOutletDisconnectedCallCountValue = 0 @@ -67,6 +71,11 @@ export class OutletController extends BaseOutletController { this.betaOutletDisconnectedCallCountValue++ } + gammaOutletConnected(_outlet: Controller, element: Element) { + if (this.hasConnectedClass) element.classList.add(this.connectedClass) + this.gammaOutletConnectedCallCountValue++ + } + namespacedEpsilonOutletConnected(_outlet: Controller, element: Element) { if (this.hasConnectedClass) element.classList.add(this.connectedClass) this.namespacedEpsilonOutletConnectedCallCountValue++ diff --git a/src/tests/modules/core/outlet_tests.ts b/src/tests/modules/core/outlet_tests.ts index 0ff686d8..fb87ba12 100644 --- a/src/tests/modules/core/outlet_tests.ts +++ b/src/tests/modules/core/outlet_tests.ts @@ -132,7 +132,7 @@ export default class OutletTests extends ControllerTestCase(OutletController) { this.assert.throws(() => this.controller.alphaOutletElement) } - "test outlet connected callback fires"() { + async "test outlet connected callback fires"() { const alphaOutlets = this.controller.alphaOutletElements.filter((outlet) => outlet.classList.contains("connected")) this.assert.equal(alphaOutlets.length, 2) @@ -149,13 +149,12 @@ export default class OutletTests extends ControllerTestCase(OutletController) { async "test outlet connected callback when element is inserted"() { const betaOutletElement = document.createElement("div") - betaOutletElement.setAttribute("class", "beta") - betaOutletElement.setAttribute("data-controller", "beta") + await this.setAttribute(betaOutletElement, "class", "beta") + await this.setAttribute(betaOutletElement, "data-controller", "beta") this.assert.equal(this.controller.betaOutletConnectedCallCountValue, 2) - this.controller.element.appendChild(betaOutletElement) - await this.nextFrame + await this.appendChild(this.controller.element, betaOutletElement) this.assert.equal(this.controller.betaOutletConnectedCallCountValue, 3) this.assert.ok( @@ -164,8 +163,7 @@ export default class OutletTests extends ControllerTestCase(OutletController) { ) this.assert.ok(betaOutletElement.isConnected, "element is present in document") - this.findElement("#container").appendChild(betaOutletElement.cloneNode(true)) - await this.nextFrame + await this.appendChild("#container", betaOutletElement.cloneNode(true)) this.assert.equal(this.controller.betaOutletConnectedCallCountValue, 4) } @@ -175,9 +173,8 @@ export default class OutletTests extends ControllerTestCase(OutletController) { this.assert.equal(this.controller.betaOutletConnectedCallCountValue, 2) - element.setAttribute("data-controller", "beta") - element.classList.add("beta") - await this.nextFrame + await this.setAttribute(element, "data-controller", "beta") + await this.setAttribute(element, "class", "beta") this.assert.equal(this.controller.betaOutletConnectedCallCountValue, 3) this.assert.ok(element.classList.contains("connected"), `expected "${element.className}" to contain "connected"`) @@ -189,8 +186,7 @@ export default class OutletTests extends ControllerTestCase(OutletController) { this.assert.equal(this.controller.betaOutletConnectedCallCountValue, 2) - element.classList.add("beta") - await this.nextFrame + await this.setAttribute(element, "class", "beta") this.assert.equal(this.controller.betaOutletConnectedCallCountValue, 3) this.assert.ok(element.classList.contains("connected"), `expected "${element.className}" to contain "connected"`) @@ -202,8 +198,7 @@ export default class OutletTests extends ControllerTestCase(OutletController) { this.assert.equal(this.controller.betaOutletConnectedCallCountValue, 2) - element.setAttribute(`data-controller`, "beta") - await this.nextFrame + await this.setAttribute(element, "data-controller", "beta") this.assert.equal(this.controller.betaOutletConnectedCallCountValue, 3) this.assert.ok(element.classList.contains("connected"), `expected "${element.className}" to contain "connected"`) @@ -236,8 +231,7 @@ export default class OutletTests extends ControllerTestCase(OutletController) { `expected "${disconnectedAlpha.className}" not to contain "disconnected"` ) - disconnectedAlpha.parentElement?.removeChild(disconnectedAlpha) - await this.nextFrame + await this.remove(disconnectedAlpha) this.assert.equal(this.controller.alphaOutletDisconnectedCallCountValue, 1) this.assert.ok( @@ -256,8 +250,7 @@ export default class OutletTests extends ControllerTestCase(OutletController) { `expected "${disconnectedEpsilon.className}" not to contain "disconnected"` ) - disconnectedEpsilon.parentElement?.removeChild(disconnectedEpsilon) - await this.nextFrame + await this.remove(disconnectedEpsilon) this.assert.equal(this.controller.namespacedEpsilonOutletDisconnectedCallCountValue, 1) this.assert.ok( @@ -276,8 +269,7 @@ export default class OutletTests extends ControllerTestCase(OutletController) { `expected "${element.className}" not to contain "disconnected"` ) - element.removeAttribute(`id`) - await this.nextFrame + await this.removeAttribute(element, "id") this.assert.equal(this.controller.alphaOutletDisconnectedCallCountValue, 1) this.assert.ok( @@ -296,8 +288,7 @@ export default class OutletTests extends ControllerTestCase(OutletController) { `expected "${element.className}" not to contain "disconnected"` ) - element.removeAttribute(`data-controller`) - await this.nextFrame + await this.removeAttribute(element, "data-controller") this.assert.equal(this.controller.alphaOutletDisconnectedCallCountValue, 1) this.assert.ok( @@ -306,4 +297,73 @@ export default class OutletTests extends ControllerTestCase(OutletController) { ) this.assert.ok(element.isConnected, "element is still present in document") } + + async "test outlet connect callback when the controlled element's outlet attribute is added"() { + const gamma2 = this.findElement("#gamma2") + + await this.setAttribute(this.controller.element, `data-${this.identifier}-gamma-outlet`, "#gamma2") + + this.assert.equal(this.controller.gammaOutletConnectedCallCountValue, 1) + this.assert.ok(gamma2.isConnected, "#gamma2 is still present in document") + this.assert.ok(gamma2.classList.contains("connected"), `expected "${gamma2.className}" to contain "connected"`) + } + + async "test outlet connect callback doesn't get trigged when any attribute gets added to the controller element"() { + this.assert.equal(this.controller.alphaOutletConnectedCallCountValue, 2) + this.assert.equal(this.controller.betaOutletConnectedCallCountValue, 2) + this.assert.equal(this.controller.gammaOutletConnectedCallCountValue, 0) + this.assert.equal(this.controller.namespacedEpsilonOutletConnectedCallCountValue, 2) + + await this.setAttribute(this.controller.element, "data-some-random-attribute", "#alpha1") + + this.assert.equal(this.controller.alphaOutletConnectedCallCountValue, 2) + this.assert.equal(this.controller.betaOutletConnectedCallCountValue, 2) + this.assert.equal(this.controller.gammaOutletConnectedCallCountValue, 0) + this.assert.equal(this.controller.namespacedEpsilonOutletConnectedCallCountValue, 2) + + this.assert.equal(this.controller.alphaOutletDisconnectedCallCountValue, 0) + this.assert.equal(this.controller.betaOutletDisconnectedCallCountValue, 0) + this.assert.equal(this.controller.gammaOutletDisconnectedCallCountValue, 0) + this.assert.equal(this.controller.namespacedEpsilonOutletDisconnectedCallCountValue, 0) + } + + async "test outlet connect callback when the controlled element's outlet attribute is changed"() { + const alpha1 = this.findElement("#alpha1") + const alpha2 = this.findElement("#alpha2") + + await this.setAttribute(this.controller.element, `data-${this.identifier}-alpha-outlet`, "#alpha1") + + this.assert.equal(this.controller.alphaOutletConnectedCallCountValue, 2) + this.assert.equal(this.controller.alphaOutletDisconnectedCallCountValue, 1) + this.assert.ok(alpha1.isConnected, "alpha1 is still present in document") + this.assert.ok(alpha2.isConnected, "alpha2 is still present in document") + this.assert.ok(alpha1.classList.contains("connected"), `expected "${alpha1.className}" to contain "connected"`) + this.assert.notOk( + alpha1.classList.contains("disconnected"), + `expected "${alpha1.className}" to contain "disconnected"` + ) + this.assert.ok( + alpha2.classList.contains("disconnected"), + `expected "${alpha2.className}" to contain "disconnected"` + ) + } + + async "test outlet disconnected callback when the controlled element's outlet attribute is removed"() { + const alpha1 = this.findElement("#alpha1") + const alpha2 = this.findElement("#alpha2") + + await this.removeAttribute(this.controller.element, `data-${this.identifier}-alpha-outlet`) + + this.assert.equal(this.controller.alphaOutletDisconnectedCallCountValue, 2) + this.assert.ok(alpha1.isConnected, "#alpha1 is still present in document") + this.assert.ok(alpha2.isConnected, "#alpha2 is still present in document") + this.assert.ok( + alpha1.classList.contains("disconnected"), + `expected "${alpha1.className}" to contain "disconnected"` + ) + this.assert.ok( + alpha2.classList.contains("disconnected"), + `expected "${alpha2.className}" to contain "disconnected"` + ) + } }