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 [#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.

[#336]: https://3.basecamp.com/2914079/buckets/20224425/todos/3391985862
  • Loading branch information
seanpdoyle committed Jan 20, 2021
1 parent c47f551 commit c89e2cc
Show file tree
Hide file tree
Showing 6 changed files with 122 additions and 1 deletion.
23 changes: 23 additions & 0 deletions docs/reference/targets.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,29 @@ if (this.hasResultsTarget) {
}
```

## Addition and Removal Callbacks

Target _element callbacks_ let you respond whenever a target element is added or
removed within the controller's element.

Define a method `[name]TargetAdded` or `[name]TargetRemoved` in the controller, where `[name]` is the name of the target you want to observe for additions or removals. The method receives the element as the first argument.

Stimulus invokes each change callback any time its target elements are added or removed.

```js
export default class extends Controller {
static targets = [ "input" ]

inputTargetAdded(element) {
element.classList.add("added-animation")
}

inputTargetRemoved(element) {
element.classList.add("removed-animation")
}
}
```

## Naming Conventions

Always use camelCase to specify target names, since they map directly to properties on your controller.
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
69 changes: 69 additions & 0 deletions packages/@stimulus/core/src/target_observer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
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.receiver[method]
if (typeof callback == "function") {
callback.call(this.receiver, element)
}
}

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

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

private get identifier() {
return this.context.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 c89e2cc

Please sign in to comment.