Skip to content

Commit 06338f7

Browse files
committed
Fire callbacks when targets are added or removed
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
1 parent dc101fc commit 06338f7

File tree

8 files changed

+147
-2
lines changed

8 files changed

+147
-2
lines changed

docs/reference/targets.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,29 @@ if (this.hasResultsTarget) {
8888
}
8989
```
9090

91+
## Addition and Removal Callbacks
92+
93+
Target _element callbacks_ let you respond whenever a target element is added or
94+
removed within the controller's element.
95+
96+
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.
97+
98+
Stimulus invokes each element callback any time its target elements are added or removed after `connect()` and before `disconnect()` lifecycle hooks.
99+
100+
```js
101+
export default class extends Controller {
102+
static targets = [ "input" ]
103+
104+
inputTargetAdded(element) {
105+
element.classList.add("added-animation")
106+
}
107+
108+
inputTargetRemoved(element) {
109+
element.classList.add("removed-animation")
110+
}
111+
}
112+
```
113+
91114
## Naming Conventions
92115

93116
Always use camelCase to specify target names, since they map directly to properties on your controller.

packages/@stimulus/core/src/context.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,20 +7,23 @@ import { Module } from "./module"
77
import { Schema } from "./schema"
88
import { Scope } from "./scope"
99
import { ValueObserver } from "./value_observer"
10+
import { TargetObserver } from "./target_observer"
1011

1112
export class Context implements ErrorHandler {
1213
readonly module: Module
1314
readonly scope: Scope
1415
readonly controller: Controller
1516
private bindingObserver: BindingObserver
1617
private valueObserver: ValueObserver
18+
private targetObserver: TargetObserver
1719

1820
constructor(module: Module, scope: Scope) {
1921
this.module = module
2022
this.scope = scope
2123
this.controller = new module.controllerConstructor(this)
2224
this.bindingObserver = new BindingObserver(this, this.dispatcher)
2325
this.valueObserver = new ValueObserver(this, this.controller)
26+
this.targetObserver = new TargetObserver(this, this.controller)
2427

2528
try {
2629
this.controller.initialize()
@@ -38,9 +41,13 @@ export class Context implements ErrorHandler {
3841
} catch (error) {
3942
this.handleError(error, "connecting controller")
4043
}
44+
45+
this.targetObserver.start()
4146
}
4247

4348
disconnect() {
49+
this.targetObserver.stop()
50+
4451
try {
4552
this.controller.disconnect()
4653
} catch (error) {
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import { Context } from "./context"
2+
import { ElementObserver, ElementObserverDelegate, parseTokenString } from "@stimulus/mutation-observers"
3+
4+
export class TargetObserver implements ElementObserverDelegate {
5+
readonly context: Context
6+
readonly receiver: any
7+
private elementObserver: ElementObserver
8+
9+
constructor(context: Context, receiver: any) {
10+
this.context = context
11+
this.receiver = receiver
12+
this.elementObserver = new ElementObserver(this.context.element, this)
13+
}
14+
15+
start() {
16+
this.elementObserver.start()
17+
}
18+
19+
stop() {
20+
this.elementObserver.stop()
21+
}
22+
23+
matchElement(element: Element): boolean {
24+
return element.hasAttribute(this.attributeName)
25+
}
26+
27+
matchElementsInTree(tree: Element): Element[] {
28+
const match = this.matchElement(tree) ? [tree] : []
29+
const matches = Array.from(tree.querySelectorAll(this.selector))
30+
return match.concat(matches)
31+
}
32+
33+
elementMatched(element: Element): void {
34+
const value = element.getAttribute(this.attributeName) || ""
35+
const tokens = parseTokenString(value, element, this.attributeName)
36+
37+
tokens.forEach((token) => this.dispatchCallback(`${token.content}TargetAdded`, element))
38+
}
39+
40+
elementUnmatched(element: Element): void {
41+
const value = element.getAttribute(this.attributeName) || ""
42+
const tokens = parseTokenString(value, element, this.attributeName)
43+
44+
tokens.forEach((token) => this.dispatchCallback(`${token.content}TargetRemoved`, element))
45+
}
46+
47+
private dispatchCallback(method: string, element: Element) {
48+
const callback = this.receiver[method]
49+
if (typeof callback == "function") {
50+
callback.call(this.receiver, element)
51+
}
52+
}
53+
54+
private get selector() {
55+
return `[${this.attributeName}]`
56+
}
57+
58+
private get attributeName() {
59+
return `data-${this.identifier}-target`
60+
}
61+
62+
private get identifier() {
63+
return this.context.identifier
64+
}
65+
}

packages/@stimulus/core/src/tests/controllers/target_controller.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,4 +18,12 @@ export class TargetController extends BaseTargetController {
1818
inputTarget!: Element | null
1919
inputTargets!: Element[]
2020
hasInputTarget!: boolean
21+
22+
inputTargetAdded(element: Element) {
23+
element.classList.add("added")
24+
}
25+
26+
inputTargetRemoved(element: Element) {
27+
element.classList.add("removed")
28+
}
2129
}

packages/@stimulus/core/src/tests/modules/target_tests.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,4 +62,37 @@ export default class TargetTests extends ControllerTestCase(TargetController) {
6262
this.assert.equal(this.controller.betaTargets.length, 0)
6363
this.assert.throws(() => this.controller.betaTarget)
6464
}
65+
66+
"test target added callback fires after connect()"() {
67+
const addedInputs = this.controller.inputTargets.filter(target => target.classList.contains("added"))
68+
69+
this.assert.equal(addedInputs.length, 0)
70+
}
71+
72+
"test target added callback"() {
73+
const addedInput = document.createElement("input")
74+
addedInput.setAttribute(`data-${this.controller.identifier}-target`, "input")
75+
76+
this.controller.element.appendChild(addedInput)
77+
78+
this.assert.ok(addedInput.classList.contains("added"), "inputTargetAdded callback fired")
79+
}
80+
81+
"test target remove callback fires before disconnect()"() {
82+
const inputs = this.controller.inputTargets
83+
84+
this.controller.disconnect()
85+
86+
const removedInputs = inputs.filter(target => target.classList.contains("removed"))
87+
88+
this.assert.equal(removedInputs.length, 0)
89+
}
90+
91+
"test target removed callback"() {
92+
const removedInput = this.findElement("#input1")
93+
94+
removedInput.remove()
95+
96+
this.assert.ok(removedInput.classList.contains("removed"), "inputTargetRemoved callback fired")
97+
}
6598
}

packages/@stimulus/examples/controllers/content_loader_controller.js

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { Controller } from "stimulus"
22

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

67
connect() {
@@ -11,6 +12,14 @@ export default class extends Controller {
1112
}
1213
}
1314

15+
itemTargetAdded(target) {
16+
console.log("itemTargetAdded:", target)
17+
}
18+
19+
itemTargetRemoved(target) {
20+
console.log("itemTargetRemoved:", target)
21+
}
22+
1423
disconnect() {
1524
this.stopRefreshing()
1625
}

packages/@stimulus/examples/server.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ app.get("/", (req, res) => {
2929
})
3030

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

3535
app.get("/:page", (req, res, next) => {

packages/@stimulus/mutation-observers/src/token_list_observer.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,7 @@ export class TokenListObserver implements AttributeObserverDelegate {
102102
}
103103
}
104104

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

0 commit comments

Comments
 (0)