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

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

[#336]: #336
  • Loading branch information
seanpdoyle committed Mar 27, 2021
1 parent 3bfd886 commit 4d7f686
Show file tree
Hide file tree
Showing 10 changed files with 204 additions and 5 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 @@ -32,9 +35,11 @@ export class Context implements ErrorHandler {
connect() {
this.bindingObserver.start()
this.valueObserver.start()
this.targetObserver.start()

try {
this.controller.connect()
this.controller.isConnected = true
} catch (error) {
this.handleError(error, "connecting controller")
}
Expand All @@ -43,10 +48,12 @@ export class Context implements ErrorHandler {
disconnect() {
try {
this.controller.disconnect()
this.controller.isConnected = false
} catch (error) {
this.handleError(error, "disconnecting controller")
}

this.targetObserver.stop()
this.valueObserver.stop()
this.bindingObserver.stop()
}
Expand Down
1 change: 1 addition & 0 deletions packages/@stimulus/core/src/controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export class Controller {
static values: ValueDefinitionMap = {}

readonly context: Context
isConnected = false

constructor(context: Context) {
this.context = context
Expand Down
50 changes: 50 additions & 0 deletions packages/@stimulus/core/src/target_observer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { Context } from "./context"
import { Controller } from "./controller"
import { TokenListObserver, TokenListObserverDelegate, Token, parseTokenString } from "@stimulus/mutation-observers"

export class TargetObserver implements TokenListObserverDelegate {
readonly context: Context
readonly controller: Controller
readonly attributeName: string
private tokenListObserver: TokenListObserver

constructor(context: Context, controller: Controller) {
this.context = context
this.controller = controller
this.attributeName = `data-${context.identifier}-target`
this.tokenListObserver = new TokenListObserver(this.context.element, this.attributeName, this)
}

start() {
this.tokenListObserver.start()
}

stop() {
this.tokenListObserver.stop()
}

tokenMatched(token: Token): void {
if (this.controller.isConnected && this.containsDescendantWithToken(token.element, token.content)) {
this.dispatchCallback(`${token.content}TargetAdded`, token.element)
}
}

tokenUnmatched(token: Token): void {
if (this.controller.isConnected && !this.containsDescendantWithToken(token.element, token.content)) {
this.dispatchCallback(`${token.content}TargetRemoved`, token.element)
}
}

private containsDescendantWithToken(element: Element, content: string): boolean {
const targetTokens = parseTokenString(element.getAttribute(this.attributeName) || "", element, this.attributeName)

return targetTokens.map(token => token.content).includes(content) && this.context.element.contains(element)
}

private dispatchCallback(method: string, element: Element) {
const callback = (this.controller as any)[method]
if (typeof callback == "function") {
callback.call(this.controller, element)
}
}
}
5 changes: 4 additions & 1 deletion packages/@stimulus/core/src/target_properties.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@ function propertiesForTargetDefinition(name: string) {
get(this: Controller) {
return this.targets.has(name)
}
}
},

[`${name}TargetAdded`]: (element: Element) => {},
[`${name}TargetRemoved`]: (element: Element) => {},
}
}
20 changes: 20 additions & 0 deletions packages/@stimulus/core/src/tests/controllers/target_controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@ class BaseTargetController extends Controller {
}

export class TargetController extends BaseTargetController {
static classes = [ "added", "removed" ]
static targets = [ "beta", "input" ]
static values = { inputTargetAddedCallCount: Number, inputTargetRemovedCallCount: Number }

betaTarget!: Element | null
betaTargets!: Element[]
Expand All @@ -18,4 +20,22 @@ export class TargetController extends BaseTargetController {
inputTarget!: Element | null
inputTargets!: Element[]
hasInputTarget!: boolean

hasAddedClass!: boolean
hasRemovedClass!: boolean
addedClass!: string
removedClass!: string

inputTargetAddedCallCountValue = 0
inputTargetRemovedCallCountValue = 0

inputTargetAdded(element: Element) {
this.inputTargetAddedCallCountValue++
if (this.hasAddedClass) element.classList.add(this.addedClass)
}

inputTargetRemoved(element: Element) {
this.inputTargetRemovedCallCountValue++
if (this.hasRemovedClass) element.classList.add(this.removedClass)
}
}
90 changes: 88 additions & 2 deletions packages/@stimulus/core/src/tests/modules/target_tests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { TargetController } from "../controllers/target_controller"

export default class TargetTests extends ControllerTestCase(TargetController) {
fixtureHTML = `
<div data-controller="${this.identifier}">
<div data-controller="${this.identifier}" data-${this.identifier}-added-class="added" data-${this.identifier}-removed-class="removed">
<div data-${this.identifier}-target="alpha" id="alpha1"></div>
<div data-${this.identifier}-target="alpha" id="alpha2"></div>
<div data-${this.identifier}-target="beta" id="beta1">
Expand All @@ -12,7 +12,7 @@ export default class TargetTests extends ControllerTestCase(TargetController) {
<div data-controller="${this.identifier}" id="child">
<div data-${this.identifier}-target="delta" id="delta1"></div>
</div>
<textarea data-${this.identifier}-target="input" id="input1"></textarea>
<textarea data-${this.identifier}-target="omega input" id="input1"></textarea>
</div>
`

Expand Down Expand Up @@ -62,4 +62,90 @@ 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)
this.assert.equal(this.controller.inputTargetAddedCallCountValue, 0)
}

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

this.controller.element.appendChild(addedInput)
await this.nextFrame

this.assert.equal(this.controller.inputTargetAddedCallCountValue, 1)
this.assert.ok(addedInput.classList.contains("added"), `expected "${addedInput.className}" to contain "added"`)
this.assert.ok(addedInput.isConnected, "element is present in document")
}

async "test target added callback when present element adds the target attribute"() {
const element = this.findElement("#child")

element.setAttribute(`data-${this.controller.identifier}-target`, "input")
await this.nextFrame

this.assert.equal(this.controller.inputTargetAddedCallCountValue, 1)
this.assert.ok(element.classList.contains("added"), `expected "${element.className}" to contain "added"`)
this.assert.ok(element.isConnected, "element is still present in document")
}

async "test target added callback when present element adds a token to an existing target attribute"() {
const element = this.findElement("#alpha1")

element.setAttribute(`data-${this.controller.identifier}-target`, "alpha input")
await this.nextFrame

this.assert.equal(this.controller.inputTargetAddedCallCountValue, 1)
this.assert.ok(element.classList.contains("added"), `expected "${element.className}" to contain "added"`)
this.assert.ok(element.isConnected, "element is still present in document")
}

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

this.controller.disconnect()
await this.nextFrame

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

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

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

removedInput.remove()
await this.nextFrame

this.assert.equal(this.controller.inputTargetRemovedCallCountValue, 1)
this.assert.ok(removedInput.classList.contains("removed"), `expected "${removedInput.className}" to contain "removed"`)
this.assert.notOk(removedInput.isConnected, "element is not present in document")
}

async "test target removed callback when an element present in the document removes the target attribute"() {
const element = this.findElement("#input1")

element.removeAttribute(`data-${this.controller.identifier}-target`)
await this.nextFrame

this.assert.equal(this.controller.inputTargetRemovedCallCountValue, 1)
this.assert.ok(element.classList.contains("removed"), `expected "${element.className}" to contain "removed"`)
this.assert.ok(element.isConnected, "element is still present in document")
}

async "test target removed callback does not fire when the target name is present after the attribute change"() {
const element = this.findElement("#input1")

element.setAttribute(`data-${this.controller.identifier}-target`, "input")
await this.nextFrame

this.assert.equal(this.controller.inputTargetRemovedCallCountValue, 0)
this.assert.notOk(element.classList.contains("removed"), `expected "${element.className}" not to contain "removed"`)
this.assert.ok(element.isConnected, "element is still present in document")
}
}
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 4d7f686

Please sign in to comment.