forked from hotwired/stimulus
-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Proposal: Mutation Observation Syntax
Add attribute-level support for monitoring changes to attributes on the marked element _and_ its descendants. The `data-mutation` syntax draws direct inspiration from the `[data-action]` syntax for routing browser events. Similar to how the Action syntax supports [event listener options][], the Mutation syntax would support [MutationObserverInit options][] like `!subtree`. The proposed hooks only cover _attribute_ mutations, since the proposal made by [hotwired#367][] should cover `childList` type mutations like the addition or removal of controller targets. One alternative could involve combining `[data-mutation]` and `[data-action]` into a single DOMTokenList, and using additional symbols like `@...` or wrapping `[...]` as a differentiators (e.g. `@aria-expanded->disclosure#toggle` or `[aria-expanded]->disclosure#toggle`). Another could push this responsibility application-side by introducing more publicly available `MutationObserver` utilities like those used for `DOMTokenList` parsing or deconstructing the `[data-action]` directives. Once available, those utilities could be used by consumers to listen for their own mutations and "route" them to actions by combining action directive parsing and `Application.getControllerForElementAndIdentifier(element, identifier)` to invoke fuctions on a `Controller` instance. [hotwired#367]: hotwired#367 [event listen options]: https://stimulus.hotwire.dev/reference/actions#options [MutationObserverInit options]: https://developer.mozilla.org/en-US/docs/Web/API/MutationObserverInit#properties
- Loading branch information
1 parent
c654078
commit 3659531
Showing
5 changed files
with
299 additions
and
2 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,136 @@ | ||
--- | ||
permalink: /reference/mutations | ||
order: 06 | ||
--- | ||
|
||
# Mutations | ||
|
||
_Mutations_ are how you handle changes to DOM elements and their attributes in your controllers. | ||
|
||
<meta data-controller="callout" data-callout-text-value="aria-expanded->combobox#toggle"> | ||
|
||
```html | ||
<div data-controller="combobox"> | ||
<input type="search" data-mutation="aria-expanded->combobox#toggle"> | ||
</div> | ||
``` | ||
|
||
<meta data-controller="callout" data-callout-text-value="toggle"> | ||
|
||
```js | ||
// controllers/combobox_controller.js | ||
import { Controller } from "@hotwired/stimulus" | ||
|
||
export default class extends Controller { | ||
toggle(mutationRecords) { | ||
// … | ||
} | ||
} | ||
``` | ||
|
||
A mutation is a connection between: | ||
|
||
* a controller method | ||
* the controller's element | ||
* a DOM mutation observer | ||
|
||
## Descriptors | ||
|
||
The `data-mutation` value `aria-expanded->combobox#toggle` is called a _mutation descriptor_. In this descriptor: | ||
|
||
* `aria-expanded` is the name of the attribute to listen for changes to | ||
* `combobox` is the controller identifier | ||
* `toggle` is the name of the method to invoke | ||
|
||
### Mutations Shorthand | ||
|
||
Stimulus lets you shorten the mutation descriptors when observing mutations to _any_ attribute, by omitting the attribute name: | ||
|
||
<meta data-controller="callout" data-callout-text-value="combobox#toggle"> | ||
|
||
```html | ||
<div data-controller="combobox" data-mutation="combobox#toggle">…</div> | ||
``` | ||
|
||
### Options | ||
|
||
You can append one or more _mutation options_ to a mutation descriptor if you | ||
need to specify [MutationObserverInit | ||
options](https://developer.mozilla.org/en-US/docs/Web/API/MutationObserverInit). | ||
|
||
<meta data-controller="callout" data-callout-text-value="aria-expanded"> | ||
<meta data-controller="callout" data-callout-text-value=":subtree"> | ||
|
||
```html | ||
<div data-controller="combobox" data-mutation="aria-expanded->combobox#toggle:subtree">…</div> | ||
``` | ||
|
||
When provided, the attribute name serves as the `attributeFilter` option and | ||
defaults the `{ attribute: true }`. | ||
|
||
Stimulus supports the following mutation options: | ||
|
||
Mutation option | MutationObserver option | ||
------------------------- | ------------------------- | ||
`:subtree` | `{ subtree: true }` | ||
`:childList` | `{ childList: true }` | ||
`:attributes` | `{ attributes: true }` | ||
`:attributeOldValue` | `{ attributeOldValue: true }` | ||
`:characterData` | `{ characterData: true }` | ||
`:characterDataOldValue ` | `{ characterData: true }` | ||
|
||
|
||
## MutationRecord Objects | ||
|
||
A _mutation method_ is the method in a controller which serves as an mutation's event listener. | ||
|
||
The first argument to a mutation method is an array of DOM | ||
[MutationRecord](https://developer.mozilla.org/en-US/docs/Web/API/MutationRecord) | ||
_objects_. You may want access to the event for a number of reasons, including: | ||
|
||
* to find out which element was mutated | ||
* to read the attribute's previous value | ||
|
||
The following basic properties are common to all events: | ||
|
||
MutationRecord Property | Value | ||
----------------------- | ----- | ||
mutationRecord.type | The name of the event (e.g. `"click"`) | ||
mutationRecord.target | The target that dispatched the event (i.e. the innermost element that was clicked) | ||
|
||
## Multiple Mutations | ||
|
||
The `data-mutation` attribute's value is a space-separated list of mutation descriptors. | ||
|
||
It's common for any given element to have many actions. For example, the following dialog element calls a `modal` controller's `backdrop()` method and a `focus` controller's `trap()` method when the `open` attribute changes: | ||
|
||
<meta data-controller="callout" data-callout-text-value="open->modal#backdrop"> | ||
<meta data-controller="callout" data-callout-text-value="open->focus#trap"> | ||
|
||
```html | ||
<dialog data-action="open->modal#backdrop open->focus#trap"> | ||
``` | ||
|
||
When an element has more than one action for the same mutation, Stimulus invokes the actions from left to right in the order that their descriptors appear. | ||
|
||
## Naming Conventions | ||
|
||
Always use camelCase to specify action names, since they map directly to methods on your controller. | ||
|
||
Avoid action names that repeat the mutation's name, such as `open`, `onOpen`, or `handleOpen`: | ||
|
||
<meta data-controller="callout" data-callout-text-value="#open" data-callout-type-value="avoid"> | ||
|
||
```html | ||
<button data-action="open->modal#open">Don't</dialog> | ||
``` | ||
|
||
Instead, name your action methods based on what will happen when they're called: | ||
|
||
<meta data-controller="callout" data-callout-text-value="#backdrop" data-callout-type-value="prefer"> | ||
|
||
```html | ||
<dialog data-action="open->modal#backdrop">Do</dialog> | ||
``` | ||
|
||
This will help you reason about the behavior of a block of HTML without having to look at the controller source. |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,95 @@ | ||
import { LogControllerTestCase } from "../cases/log_controller_test_case" | ||
|
||
export default class MutationTests extends LogControllerTestCase { | ||
identifier = "c" | ||
fixtureHTML = ` | ||
<div data-controller="c" data-mutation="contenteditable->c#logMutation"> | ||
<button data-mutation="c#logMutation"><span>Log</span></button> | ||
<section data-mutation="id->c#logMutation"><p>Log</p></section> | ||
<div id="outer" data-mutation="contenteditable->c#logMutation"> | ||
<div id="inner" data-controller="c" data-mutation="contenteditable->c#logMutation class->c#logMutation"></div> | ||
</div> | ||
<div id="with-options" data-controller="c" data-mutation="contenteditable->c#logMutation:!subtree"> | ||
<div>With Options Child</div> | ||
</div> | ||
<div id="multiple" data-mutation="class->c#logMutation class->c#logMutation2"></div> | ||
</div> | ||
<div id="outside"></div> | ||
<svg id="svgRoot" data-controller="c" data-mutation="fill->c#logMutation"> | ||
<circle id="svgChild" data-mutation="stroke->c#logMutation" cx="5" cy="5" r="5"> | ||
</svg> | ||
` | ||
|
||
async "test default mutation"() { | ||
await this.setAttribute("button", "id", "button") | ||
this.assertMutations({ type: "attribute", attributeName: "id", oldValue: null }) | ||
|
||
await this.removeAttribute("button", "id") | ||
this.assertMutations({ type: "attribute", attributeName: "id", oldValue: "button" }) | ||
} | ||
|
||
async "test bubbling mutations"() { | ||
await this.setAttribute("span", "id", "span") | ||
this.assertMutations({ type: "attribute", attributeName: "id", oldValue: null }) | ||
|
||
await this.removeAttribute("span", "id") | ||
this.assertMutations({ type: "attribute", attributeName: "id", oldValue: "button" }) | ||
} | ||
|
||
async "test non-bubbling mutations"() { | ||
await this.setAttribute("section p", "role", "presentation") | ||
this.assertNoActions() | ||
|
||
const section = await this.findElement("section") | ||
await section.insertAdjacentHTML("beforeend", "<div>Ignored</div>") | ||
this.assertNoActions() | ||
|
||
const div = await this.findElement("section div") | ||
await section.removeChild(div) | ||
this.assertNoActions() | ||
} | ||
|
||
async "test nested mutations"() { | ||
const innerController = this.controllers[1] | ||
|
||
await this.setAttribute("#inner", "contenteditable", "") | ||
this.assertMutations({ controller: innerController, type: "attribute", attributeName: "contenteditable", oldValue: null }) | ||
|
||
await this.removeAttribute("#inner", "contenteditable") | ||
this.assertMutations({ controller: innerController, type: "attribute", attributeName: "contenteditable", oldValue: "" }) | ||
|
||
await this.setAttribute("#inner", "class", "mutated") | ||
this.assertMutations({ controller: innerController, type: "attribute", attributeName: "class", oldValue: null }) | ||
} | ||
|
||
async "test with options"() { | ||
await this.setAttribute("#with-options div", "contenteditable", "") | ||
this.assertNoMutations() | ||
|
||
await this.setAttribute("#with-options", "contenteditable", "") | ||
this.assertMutations({ type: "attribute", attributeName: "class", oldValue: null }) | ||
} | ||
|
||
async "test multiple mutations"() { | ||
await this.setAttribute("#multiple", "class", "mutated") | ||
this.assertMutations( | ||
{ name: "logMutation", attributeName: "class", oldValue: null }, | ||
{ name: "logMutation2", attributeName: "class", oldValue: null }, | ||
) | ||
|
||
await this.removeAttribute("#multiple", "class") | ||
this.assertMutations( | ||
{ name: "logMutation", attributeName: "class", oldValue: "mutated" }, | ||
{ name: "logMutation2", attributeName: "class", oldValue: "mutated" }, | ||
) | ||
} | ||
|
||
async "test mutations on svg elements"() { | ||
await this.setAttribute("#svgRoot", "fill", "#fff") | ||
await this.setAttribute("#svgChild", "stroke", "#000") | ||
this.assertActions( | ||
{ name: "mutationLog", attributeName: "fill" }, | ||
{ name: "mutationLog", attributeName: "stroke" } | ||
) | ||
} | ||
} |