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`
_after_ being connected and _before_ being disconnected.

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 Feb 7, 2021
1 parent dc101fc commit 06338f7
Show file tree
Hide file tree
Showing 8 changed files with 147 additions and 2 deletions.
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 element callback any time its target elements are added or removed after `connect()` and before `disconnect()` lifecycle hooks.

```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.
7 changes: 7 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 @@ -38,9 +41,13 @@ export class Context implements ErrorHandler {
} catch (error) {
this.handleError(error, "connecting controller")
}

this.targetObserver.start()
}

disconnect() {
this.targetObserver.stop()

try {
this.controller.disconnect()
} catch (error) {
Expand Down
65 changes: 65 additions & 0 deletions packages/@stimulus/core/src/target_observer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
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.context.element, this)
}

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

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

matchElement(element: Element): boolean {
return element.hasAttribute(this.attributeName)
}

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
}
}
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")
}
}
33 changes: 33 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,37 @@ 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 fires after connect()"() {
const addedInputs = this.controller.inputTargets.filter(target => target.classList.contains("added"))

this.assert.equal(addedInputs.length, 0)
}

"test target added callback"() {
const addedInput = document.createElement("input")
addedInput.setAttribute(`data-${this.controller.identifier}-target`, "input")

this.controller.element.appendChild(addedInput)

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

"test target remove callback fires before disconnect()"() {
const inputs = this.controller.inputTargets

this.controller.disconnect()

const removedInputs = inputs.filter(target => target.classList.contains("removed"))

this.assert.equal(removedInputs.length, 0)
}

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

removedInput.remove()

this.assert.ok(removedInput.classList.contains("removed"), "inputTargetRemoved callback fired")
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Controller } from "stimulus"

export default class extends Controller {
static targets = ["item"]
static values = { url: String, refreshInterval: Number }

connect() {
Expand All @@ -11,6 +12,14 @@ export default class extends Controller {
}
}

itemTargetAdded(target) {
console.log("itemTargetAdded:", target)
}

itemTargetRemoved(target) {
console.log("itemTargetRemoved:", target)
}

disconnect() {
this.stopRefreshing()
}
Expand Down
2 changes: 1 addition & 1 deletion packages/@stimulus/examples/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ app.get("/", (req, res) => {
})

app.get("/uptime", (req, res, next) => {
res.send(process.uptime().toString())
res.send(`<span data-content-loader-target="item">${process.uptime().toString()}</span>`)
})

app.get("/:page", (req, res, next) => {
Expand Down
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 06338f7

Please sign in to comment.