diff --git a/docs/reference/lifecycle_callbacks.md b/docs/reference/lifecycle_callbacks.md index dc5bb55e..ab1eb90e 100644 --- a/docs/reference/lifecycle_callbacks.md +++ b/docs/reference/lifecycle_callbacks.md @@ -28,6 +28,8 @@ Method | Invoked by Stimulus… initialize() | Once, when the controller is first instantiated connect() | Anytime the controller is connected to the DOM [name]TargetConnected(target: Element) | Anytime a target is connected to the DOM +attributeChanged(attributeName: string, oldValue: string | null, newValue: string | null) | Anytime the controller element's attributes change +[name]TargetAttributeChanged(target: Element, attributeName: string, oldValue: string | null, newValue: string | null) | Anytime a target element's attributes change disconnect() | Anytime the controller is disconnected from the DOM [name]TargetDisconnected(target: Element) | Anytime a target is disconnected from the DOM diff --git a/src/core/context.ts b/src/core/context.ts index 501aaf69..659c096b 100644 --- a/src/core/context.ts +++ b/src/core/context.ts @@ -8,14 +8,16 @@ import { Schema } from "./schema" import { Scope } from "./scope" import { ValueObserver } from "./value_observer" import { TargetObserver, TargetObserverDelegate } from "./target_observer" +import { ElementObserver, ElementObserverDelegate } from "../mutation-observers/element_observer" -export class Context implements ErrorHandler, TargetObserverDelegate { +export class Context implements ErrorHandler, TargetObserverDelegate, ElementObserverDelegate { readonly module: Module readonly scope: Scope readonly controller: Controller private bindingObserver: BindingObserver private valueObserver: ValueObserver private targetObserver: TargetObserver + private elementObserver: ElementObserver constructor(module: Module, scope: Scope) { this.module = module @@ -24,6 +26,7 @@ export class Context implements ErrorHandler, TargetObserverDelegate { this.bindingObserver = new BindingObserver(this, this.dispatcher) this.valueObserver = new ValueObserver(this, this.controller) this.targetObserver = new TargetObserver(this, this) + this.elementObserver = new ElementObserver(this.element, this, { subtree: false, childList: false, attributeOldValue: true }) try { this.controller.initialize() @@ -37,6 +40,7 @@ export class Context implements ErrorHandler, TargetObserverDelegate { this.bindingObserver.start() this.valueObserver.start() this.targetObserver.start() + this.elementObserver.start() try { this.controller.connect() @@ -54,6 +58,7 @@ export class Context implements ErrorHandler, TargetObserverDelegate { this.handleError(error, "disconnecting controller") } + this.elementObserver.stop() this.targetObserver.stop() this.valueObserver.stop() this.bindingObserver.stop() @@ -109,6 +114,27 @@ export class Context implements ErrorHandler, TargetObserverDelegate { this.invokeControllerMethod(`${name}TargetDisconnected`, element) } + targetAttributeChanged(element: Element, name: string, attributeName: string, oldValue: string | null, newValue: string | null) { + this.invokeControllerMethod(`${name}TargetAttributeChanged`, element, attributeName, oldValue, newValue) + } + + // Element observer delegate + + matchElement(element: Element) { + return element === this.element + } + + matchElementsInTree(tree: Element) { + return this.matchElement(tree) ? [tree] : [] + } + + elementAttributeChanged(element: Element, attributeName: string, mutation: MutationRecord) { + const oldValue = mutation.oldValue + const newValue = element.getAttribute(attributeName) + + this.elementObserver?.pause(() => this.invokeControllerMethod("attributeChanged", attributeName, oldValue, newValue)) + } + // Private invokeControllerMethod(methodName: string, ...args: any[]) { diff --git a/src/core/target_observer.ts b/src/core/target_observer.ts index 05230ab9..3f556269 100644 --- a/src/core/target_observer.ts +++ b/src/core/target_observer.ts @@ -1,17 +1,19 @@ import { Multimap } from "../multimap" -import { Token, TokenListObserver, TokenListObserverDelegate } from "../mutation-observers" +import { ElementObserver, ElementObserverDelegate, Token, TokenListObserver, TokenListObserverDelegate } from "../mutation-observers" import { Context } from "./context" export interface TargetObserverDelegate { targetConnected(element: Element, name: string): void targetDisconnected(element: Element, name: string): void + targetAttributeChanged(element: Element, name: string, attributeName: string, oldValue: string | null, newValue: string | null): void } -export class TargetObserver implements TokenListObserverDelegate { +export class TargetObserver implements ElementObserverDelegate, TokenListObserverDelegate { readonly context: Context readonly delegate: TargetObserverDelegate readonly targetsByName: Multimap private tokenListObserver?: TokenListObserver + private elementObserver?: ElementObserver constructor(context: Context, delegate: TargetObserverDelegate) { this.context = context @@ -24,6 +26,10 @@ export class TargetObserver implements TokenListObserverDelegate { this.tokenListObserver = new TokenListObserver(this.element, this.attributeName, this) this.tokenListObserver.start() } + if (!this.elementObserver) { + this.elementObserver = new ElementObserver(this.element, this, { attributeOldValue: true }) + this.elementObserver.start() + } } stop() { @@ -32,6 +38,10 @@ export class TargetObserver implements TokenListObserverDelegate { this.tokenListObserver.stop() delete this.tokenListObserver } + if (this.elementObserver) { + this.elementObserver.stop() + delete this.elementObserver + } } // Token list observer delegate @@ -46,6 +56,22 @@ export class TargetObserver implements TokenListObserverDelegate { this.disconnectTarget(element, name) } + // Element observer delegate + + matchElement(element: Element) { + return this.targetsByName.hasValue(element) + } + + matchElementsInTree(tree: Element) { + return this.targetsByName.values + } + + elementAttributeChanged(element: Element, attributeName: string, mutationRecord: MutationRecord) { + for (const name of this.targetsByName.getKeysForValue(element)) { + this.mutateTarget(element, name, attributeName, mutationRecord) + } + } + // Target management connectTarget(element: Element, name: string) { @@ -70,6 +96,14 @@ export class TargetObserver implements TokenListObserverDelegate { } } + mutateTarget(element: Element, name: string, attributeName: string, { oldValue }: MutationRecord) { + const newValue = element.getAttribute(attributeName) + + this.elementObserver?.pause(() => { + this.delegate.targetAttributeChanged(element, name, attributeName, oldValue, newValue) + }) + } + // Private private get attributeName() { diff --git a/src/mutation-observers/element_observer.ts b/src/mutation-observers/element_observer.ts index aa172c97..bd12f713 100644 --- a/src/mutation-observers/element_observer.ts +++ b/src/mutation-observers/element_observer.ts @@ -4,7 +4,7 @@ export interface ElementObserverDelegate { elementMatched?(element: Element): void elementUnmatched?(element: Element): void - elementAttributeChanged?(element: Element, attributeName: string): void + elementAttributeChanged?(element: Element, attributeName: string, mutationRecord: MutationRecord): void } export class ElementObserver { @@ -14,24 +14,40 @@ export class ElementObserver { private elements: Set private mutationObserver: MutationObserver + private mutationObserverInit: MutationObserverInit - constructor(element: Element, delegate: ElementObserverDelegate) { + constructor(element: Element, delegate: ElementObserverDelegate, mutationObserverInit: Partial = {}) { this.element = element this.started = false this.delegate = delegate this.elements = new Set this.mutationObserver = new MutationObserver((mutations) => this.processMutations(mutations)) + this.mutationObserverInit = { attributes: true, childList: true, subtree: true, ...mutationObserverInit } } start() { if (!this.started) { this.started = true - this.mutationObserver.observe(this.element, { attributes: true, childList: true, subtree: true }) + this.mutationObserver.observe(this.element, this.mutationObserverInit) this.refresh() } } + pause(callback: () => void) { + if (this.started) { + this.mutationObserver.disconnect() + this.started = false + } + + callback() + + if (!this.started) { + this.mutationObserver.observe(this.element, this.mutationObserverInit) + this.started = true + } + } + stop() { if (this.started) { this.mutationObserver.takeRecords() @@ -68,18 +84,18 @@ export class ElementObserver { private processMutation(mutation: MutationRecord) { if (mutation.type == "attributes") { - this.processAttributeChange(mutation.target, mutation.attributeName!) + this.processAttributeChange(mutation.target, mutation.attributeName!, mutation) } else if (mutation.type == "childList") { this.processRemovedNodes(mutation.removedNodes) this.processAddedNodes(mutation.addedNodes) } } - private processAttributeChange(node: Node, attributeName: string) { + private processAttributeChange(node: Node, attributeName: string, mutationRecord: MutationRecord) { const element = node as Element if (this.elements.has(element)) { if (this.delegate.elementAttributeChanged && this.matchElement(element)) { - this.delegate.elementAttributeChanged(element, attributeName) + this.delegate.elementAttributeChanged(element, attributeName, mutationRecord) } else { this.removeElement(element) } diff --git a/src/tests/controllers/log_controller.ts b/src/tests/controllers/log_controller.ts index 397d7112..aacefa24 100644 --- a/src/tests/controllers/log_controller.ts +++ b/src/tests/controllers/log_controller.ts @@ -12,11 +12,21 @@ export type ActionLogEntry = { passive: boolean } +export type MutationLogEntry = { + attributeName: string, + controller: Controller + identifier: string + newValue: string | null, + oldValue: string | null, +} + export class LogController extends Controller { static actionLog: ActionLogEntry[] = [] initializeCount = 0 connectCount = 0 disconnectCount = 0 + attributeChangedCount = 0 + mutationLog: MutationLogEntry[] = [] initialize() { this.initializeCount++ @@ -30,6 +40,10 @@ export class LogController extends Controller { this.disconnectCount++ } + attributeChanged(attributeName: string, oldValue: string | null, newValue: string | null) { + this.recordMutation(attributeName, oldValue, newValue) + } + log(event: ActionEvent) { this.recordAction("log", event) } @@ -72,4 +86,15 @@ export class LogController extends Controller { passive: passive || false }) } + + private recordMutation(attributeName: string, oldValue: string | null, newValue: string | null) { + this.attributeChangedCount++ + this.mutationLog.push({ + attributeName, + oldValue, + newValue, + controller: this, + identifier: this.identifier, + }) + } } diff --git a/src/tests/controllers/target_controller.ts b/src/tests/controllers/target_controller.ts index ff4835af..3a1c51c9 100644 --- a/src/tests/controllers/target_controller.ts +++ b/src/tests/controllers/target_controller.ts @@ -9,9 +9,9 @@ class BaseTargetController extends Controller { } export class TargetController extends BaseTargetController { - static classes = [ "connected", "disconnected" ] + static classes = [ "connected", "disconnected", "attributeChanged" ] static targets = [ "beta", "input" ] - static values = { inputTargetConnectedCallCount: Number, inputTargetDisconnectedCallCount: Number } + static values = { inputTargetConnectedCallCount: Number, inputTargetDisconnectedCallCount: Number, betaTargetAttributeChangedCallCountValue: Number } betaTarget!: Element | null betaTargets!: Element[] @@ -23,19 +23,28 @@ export class TargetController extends BaseTargetController { hasConnectedClass!: boolean hasDisconnectedClass!: boolean + hasAttributeChangedClass!: boolean connectedClass!: string disconnectedClass!: string + attributeChangedClass!: string inputTargetConnectedCallCountValue = 0 inputTargetDisconnectedCallCountValue = 0 + betaTargetAttributeChangedCallCountValue = 0 - inputTargetConnected(element: Element) { - if (this.hasConnectedClass) element.classList.add(this.connectedClass) + inputTargetConnected(target: Element) { + if (this.hasConnectedClass) target.classList.add(this.connectedClass) this.inputTargetConnectedCallCountValue++ } - inputTargetDisconnected(element: Element) { - if (this.hasDisconnectedClass) element.classList.add(this.disconnectedClass) + inputTargetDisconnected(target: Element) { + if (this.hasDisconnectedClass) target.classList.add(this.disconnectedClass) this.inputTargetDisconnectedCallCountValue++ } + + betaTargetAttributeChanged(target: Element, attributeName: string, ...args: any[]) { + if (this.hasAttributeChangedClass) target.classList.add(this.attributeChangedClass) + this.betaTargetAttributeChangedCallCountValue++ + target.setAttribute(attributeName, args.join(",")) + } } diff --git a/src/tests/modules/core/lifecycle_tests.ts b/src/tests/modules/core/lifecycle_tests.ts index 19d11543..b6b39223 100644 --- a/src/tests/modules/core/lifecycle_tests.ts +++ b/src/tests/modules/core/lifecycle_tests.ts @@ -28,6 +28,38 @@ export default class LifecycleTests extends LogControllerTestCase { this.assert.equal(controller.disconnectCount, 1) } + async "test Controller#attributeChanged called when element changes an attribute"() { + const controller = this.controller + + this.assert.equal(controller.attributeChangedCount, 0) + + await this.setElementAttribute(this.controller.element, "data-changed", "new-value") + + const lastMutation = controller.mutationLog.pop() + this.assert.equal(controller.attributeChangedCount, 1) + this.assert.equal(lastMutation?.attributeName, "data-changed") + this.assert.equal(lastMutation?.oldValue, null) + this.assert.equal(lastMutation?.newValue, "new-value") + } + + async "test Controller#attributeChanged not called when descendant changes an attribute"() { + const controller = this.controller + this.controllerElement.insertAdjacentHTML("beforeend", `
`) + const element = controller.element.querySelector("#child") + + this.assert.equal(controller.attributeChangedCount, 0) + + await this.setElementAttribute(element!, "data-changed", "new-value") + + this.assert.equal(controller.attributeChangedCount, 0) + this.assert.equal(controller.mutationLog.length, 0) + } + + async setElementAttribute(element: Element, attributeName: string, value: string) { + element.setAttribute(attributeName, value) + await this.nextFrame + } + async reconnectControllerElement() { await this.disconnectControllerElement() await this.connectControllerElement() diff --git a/src/tests/modules/core/target_tests.ts b/src/tests/modules/core/target_tests.ts index f0f3dc08..734ec635 100644 --- a/src/tests/modules/core/target_tests.ts +++ b/src/tests/modules/core/target_tests.ts @@ -3,7 +3,7 @@ import { TargetController } from "../../controllers/target_controller" export default class TargetTests extends ControllerTestCase(TargetController) { fixtureHTML = ` -
+
@@ -63,14 +63,14 @@ export default class TargetTests extends ControllerTestCase(TargetController) { this.assert.throws(() => this.controller.betaTarget) } - "test target connected callback fires after initialize() and when calling connect()"() { + "test [target]Connected callback fires after initialize() and when calling connect()"() { const connectedInputs = this.controller.inputTargets.filter(target => target.classList.contains("connected")) this.assert.equal(connectedInputs.length, 1) this.assert.equal(this.controller.inputTargetConnectedCallCountValue, 1) } - async "test target connected callback when element is inserted"() { + async "test [target]Connected callback when element is inserted"() { const connectedInput = document.createElement("input") connectedInput.setAttribute(`data-${this.controller.identifier}-target`, "input") @@ -84,7 +84,7 @@ export default class TargetTests extends ControllerTestCase(TargetController) { this.assert.ok(connectedInput.isConnected, "element is present in document") } - async "test target connected callback when present element adds the target attribute"() { + async "test [target]Connected callback when present element adds the target attribute"() { const element = this.findElement("#alpha1") this.assert.equal(this.controller.inputTargetConnectedCallCountValue, 1) @@ -97,7 +97,7 @@ export default class TargetTests extends ControllerTestCase(TargetController) { this.assert.ok(element.isConnected, "element is still present in document") } - async "test target connected callback when element adds a token to an existing target attribute"() { + async "test [target]Connected callback when element adds a token to an existing target attribute"() { const element = this.findElement("#alpha1") this.assert.equal(this.controller.inputTargetConnectedCallCountValue, 1) @@ -110,7 +110,7 @@ export default class TargetTests extends ControllerTestCase(TargetController) { this.assert.ok(element.isConnected, "element is still present in document") } - async "test target disconnected callback fires when calling disconnect() on the controller"() { + async "test [target]Disconnected callback fires when calling disconnect() on the controller"() { this.assert.equal(this.controller.inputTargets.filter(target => target.classList.contains("disconnected")).length, 0) this.assert.equal(this.controller.inputTargetDisconnectedCallCountValue, 0) @@ -121,7 +121,7 @@ export default class TargetTests extends ControllerTestCase(TargetController) { this.assert.equal(this.controller.inputTargetDisconnectedCallCountValue, 1) } - async "test target disconnected callback when element is removed"() { + async "test [target]Disconnected callback when element is removed"() { const disconnectedInput = this.findElement("#input1") this.assert.equal(this.controller.inputTargetDisconnectedCallCountValue, 0) @@ -135,7 +135,7 @@ export default class TargetTests extends ControllerTestCase(TargetController) { this.assert.notOk(disconnectedInput.isConnected, "element is not present in document") } - async "test target disconnected callback when an element present in the document removes the target attribute"() { + async "test [target]Disconnected callback when an element present in the document removes the target attribute"() { const element = this.findElement("#input1") this.assert.equal(this.controller.inputTargetDisconnectedCallCountValue, 0) @@ -149,7 +149,7 @@ export default class TargetTests extends ControllerTestCase(TargetController) { this.assert.ok(element.isConnected, "element is still present in document") } - async "test target disconnected(), then connected() callback fired when the target name is present after the attribute change"() { + async "test [target]Disconnected, then [target]Connected callback fired when the target name is present after the attribute change"() { const element = this.findElement("#input1") this.assert.equal(this.controller.inputTargetConnectedCallCountValue, 1) @@ -164,4 +164,32 @@ export default class TargetTests extends ControllerTestCase(TargetController) { this.assert.ok(element.classList.contains("disconnected"), `expected "${element.className}" to contain "disconnected"`) this.assert.ok(element.isConnected, "element is still present in document") } + + async "test calls [target]AttributeChanged callback when target changes an attribute"() { + const beta = this.findElement("#beta1") + + this.assert.equal(this.controller.betaTargetAttributeChangedCallCountValue, 0) + this.assert.notOk(beta.classList.contains("changed"), `expected "${beta.className}" not to contain "changed"`) + + beta.setAttribute("data-changed", "newValue") + await this.nextFrame + + this.assert.equal(beta.getAttribute("data-changed"), ",newValue", `expected [data-changed] to capture the callback arguments`) + this.assert.equal(this.controller.betaTargetAttributeChangedCallCountValue, 1) + this.assert.ok(beta.classList.contains("changed"), `expected "${beta.className}" to contain "changed"`) + } + + async "test does not call [target]AttributeChanged callback when a descendant's attribute changes"() { + const beta = this.findElement("#beta1") + const gamma = this.findElement("#gamma1") + + this.assert.equal(this.controller.betaTargetAttributeChangedCallCountValue, 0) + this.assert.notOk(beta.classList.contains("changed"), `expected "${beta.className}" not to contain "changed"`) + + gamma.setAttribute("data-changed", "value") + await this.nextFrame + + this.assert.equal(this.controller.betaTargetAttributeChangedCallCountValue, 0) + this.assert.notOk(beta.classList.contains("changed"), `expected "${beta.className}" not to contain "changed"`) + } }