Skip to content

Commit

Permalink
Fire callbacks when targets are added or removed
Browse files Browse the repository at this point in the history
Closes [hotwired#336][]

---

Implements the `TargetObserver` to monitor when elements declaring
`[data-${identifier}-target]` are added or removed from a `Scope`.

In support of iterating through target tokens, export the
`TokenListObserver` module's `parseTokenString` function.

[hotwired#336]: https://3.basecamp.com/2914079/buckets/20224425/todos/3391985862
  • Loading branch information
seanpdoyle committed Jan 20, 2021
1 parent c47f551 commit 8c151c8
Show file tree
Hide file tree
Showing 5 changed files with 103 additions and 1 deletion.
5 changes: 5 additions & 0 deletions packages/@stimulus/core/src/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,20 +7,23 @@ import { Module } from "./module"
import { Schema } from "./schema"
import { Scope } from "./scope"
import { ValueObserver } from "./value_observer"
import { TargetObserver } from "./target_observer"

export class Context implements ErrorHandler {
readonly module: Module
readonly scope: Scope
readonly controller: Controller
private bindingObserver: BindingObserver
private valueObserver: ValueObserver
private targetObserver: TargetObserver

constructor(module: Module, scope: Scope) {
this.module = module
this.scope = scope
this.controller = new module.controllerConstructor(this)
this.bindingObserver = new BindingObserver(this, this.dispatcher)
this.valueObserver = new ValueObserver(this, this.controller)
this.targetObserver = new TargetObserver(this, this.controller)

try {
this.controller.initialize()
Expand All @@ -32,6 +35,7 @@ export class Context implements ErrorHandler {
connect() {
this.bindingObserver.start()
this.valueObserver.start()
this.targetObserver.start()

try {
this.controller.connect()
Expand All @@ -47,6 +51,7 @@ export class Context implements ErrorHandler {
this.handleError(error, "disconnecting controller")
}

this.targetObserver.stop()
this.valueObserver.stop()
this.bindingObserver.stop()
}
Expand Down
73 changes: 73 additions & 0 deletions packages/@stimulus/core/src/target_observer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { Context } from "./context"
import { ElementObserver, ElementObserverDelegate, parseTokenString } from "@stimulus/mutation-observers"

export class TargetObserver implements ElementObserverDelegate {
readonly context: Context
readonly receiver: any
private elementObserver: ElementObserver

constructor(context: Context, receiver: any) {
this.context = context
this.receiver = receiver
this.elementObserver = new ElementObserver(this.element, this)
}

start() {
this.elementObserver.start()
}

stop() {
this.elementObserver.stop()
}

matchElement(element: Element): boolean {
return element.matches(this.selector)
}

matchElementsInTree(tree: Element): Element[] {
const match = this.matchElement(tree) ? [tree] : []
const matches = Array.from(tree.querySelectorAll(this.selector))
return match.concat(matches)
}

elementMatched(element: Element): void {
const value = element.getAttribute(this.attributeName) || ""
const tokens = parseTokenString(value, element, this.attributeName)

tokens.forEach((token) => this.dispatchCallback(`${token.content}TargetAdded`, element))
}

elementUnmatched(element: Element): void {
const value = element.getAttribute(this.attributeName) || ""
const tokens = parseTokenString(value, element, this.attributeName)

tokens.forEach((token) => this.dispatchCallback(`${token.content}TargetRemoved`, element))
}

private dispatchCallback(method: string, element: Element) {
const callback = (this.controller as any)[method]
if (typeof callback == "function") {
callback.call(this.controller, element)
}
}

private get selector() {
return `[${this.attributeName}]`
}

private get attributeName() {
return `data-${this.identifier}-target`
}

private get controller() {
return this.context.controller
}

private get identifier() {
return this.controller.identifier
}

private get element() {
return this.context.element
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,4 +18,12 @@ export class TargetController extends BaseTargetController {
inputTarget!: Element | null
inputTargets!: Element[]
hasInputTarget!: boolean

inputTargetAdded(element: Element) {
element.classList.add("added")
}

inputTargetRemoved(element: Element) {
element.classList.add("removed")
}
}
16 changes: 16 additions & 0 deletions packages/@stimulus/core/src/tests/modules/target_tests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,4 +62,20 @@ export default class TargetTests extends ControllerTestCase(TargetController) {
this.assert.equal(this.controller.betaTargets.length, 0)
this.assert.throws(() => this.controller.betaTarget)
}

"test target added callback"() {
this.controller.element.insertAdjacentHTML("beforeend", `<input id="added-input" data-${this.controller.identifier}-target="input">`)

const addedInput = this.controller.element.querySelector("#input-added")

this.assert.ok(addedInput && addedInput.classList.contains("added"), "inputTargetAdded callback fired")
}

"test target removed callback"() {
const removedInput = this.findElement("#input1")

removedInput.remove()

this.assert.ok(removedInput && removedInput.classList.contains("removed"), "inputTargetRemoved callback fired")
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ export class TokenListObserver implements AttributeObserverDelegate {
}
}

function parseTokenString(tokenString: string, element: Element, attributeName: string): Token[] {
export function parseTokenString(tokenString: string, element: Element, attributeName: string): Token[] {
return tokenString.trim().split(/\s+/).filter(content => content.length)
.map((content, index) => ({ element, attributeName, content, index }))
}
Expand Down

0 comments on commit 8c151c8

Please sign in to comment.