Skip to content

Commit c89e2cc

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`. 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 c47f551 commit c89e2cc

File tree

6 files changed

+122
-1
lines changed

6 files changed

+122
-1
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 change callback any time its target elements are added or removed.
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: 5 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()
@@ -32,6 +35,7 @@ export class Context implements ErrorHandler {
3235
connect() {
3336
this.bindingObserver.start()
3437
this.valueObserver.start()
38+
this.targetObserver.start()
3539

3640
try {
3741
this.controller.connect()
@@ -47,6 +51,7 @@ export class Context implements ErrorHandler {
4751
this.handleError(error, "disconnecting controller")
4852
}
4953

54+
this.targetObserver.stop()
5055
this.valueObserver.stop()
5156
this.bindingObserver.stop()
5257
}
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
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.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.matches(this.selector)
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+
66+
private get element() {
67+
return this.context.element
68+
}
69+
}

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: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,4 +62,20 @@ 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"() {
67+
this.controller.element.insertAdjacentHTML("beforeend", `<input id="added-input" data-${this.controller.identifier}-target="input">`)
68+
69+
const addedInput = this.controller.element.querySelector("#input-added")
70+
71+
this.assert.ok(addedInput && addedInput.classList.contains("added"), "inputTargetAdded callback fired")
72+
}
73+
74+
"test target removed callback"() {
75+
const removedInput = this.findElement("#input1")
76+
77+
removedInput.remove()
78+
79+
this.assert.ok(removedInput && removedInput.classList.contains("removed"), "inputTargetRemoved callback fired")
80+
}
6581
}

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)