Skip to content

Commit

Permalink
Integrate with ElementInternals
Browse files Browse the repository at this point in the history
Closes [#1023][]

Replace the requirement for an `<input type="hidden">` element with
direct `<form>` integration through built-in support for
[ElementInternals][].

According to the [Form-associated custom elements][] section of [More
capable form controls][], various behaviors that the `<trix-editor>`
element was recreating are provided out of the box.

For example, the `<input type="hidden">`-`[input]` attribute pairing can
be achieved through [ElementInternals.setFormValue][]. Similarly, the
`<label>` element support can be achieved through
[ElementInternals.labels][].

[#1023]: #1023
[ElementInternals]: https://developer.mozilla.org/en-US/docs/Web/API/ElementInternals
[Form-associated custom elements]: https://web.dev/articles/more-capable-form-controls#form-associated_custom_elements
[More capable form controls]: https://web.dev/articles/more-capable-form-controls
[ElementInternals.setFormValue]: https://developer.mozilla.org/en-US/docs/Web/API/ElementInternals/setFormValue
[ElementInternals.labels]: https://developer.mozilla.org/en-US/docs/Web/API/ElementInternals/labels
  • Loading branch information
seanpdoyle committed Jan 16, 2024
1 parent 06d8b1d commit e1ad3a8
Show file tree
Hide file tree
Showing 11 changed files with 77 additions and 118 deletions.
19 changes: 15 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,16 +55,27 @@ Like an HTML `<textarea>`, `<trix-editor>` accepts `autofocus` and `placeholder`

## Integrating With Forms

To submit the contents of a `<trix-editor>` with a form, first define a hidden input field in the form and assign it an `id`. Then reference that `id` in the editor’s `input` attribute.
To label a `<trix-editor>` element, render the element with an `[id]` attribute, then render a `<label>` element with a `[for]` attribute that corresponds to the `[id]`:

```html
<label for="editor">Editor</label>
<trix-editor id="editor"></trix-editor>
```

To submit the contents of a `<trix-editor>` with a `<form>`, render the element with a `[name]` attribute and its initial value as its inner HTML.

```html
<form >
<input id="x" type="hidden" name="content">
<trix-editor input="x"></trix-editor>
<trix-editor name="content"></trix-editor>
</form>
```

Trix will automatically update the value of the hidden input field with each change to the editor.
To associate the element with a `<form>` that isn't an ancestor, render the element with a `[form]` attribute that references the `<form>` element by its `[id]`:

```html
<form id="a-form-element" ></form>
<trix-editor name="content" form="a-form-element"></trix-editor>
```

## Populating With Stored Content

Expand Down
8 changes: 7 additions & 1 deletion assets/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,11 @@
Trix.Inspector.install(event.target);
});

document.addEventListener("trix-change", function(event) {
var input = document.getElementById("input")
input.value = event.target.value
})

document.addEventListener("trix-attachment-add", function(event) {
var attachment = event.attachment;
if (attachment.file) {
Expand Down Expand Up @@ -72,7 +77,8 @@
</head>
<body>
<main>
<trix-editor autofocus class="trix-content" input="input"></trix-editor>
<label for="editor">Input</label>
<trix-editor autofocus id="editor" class="trix-content"></trix-editor>
<details id="output">
<summary>Output</summary>
<textarea readonly id="input"></textarea>
Expand Down
41 changes: 18 additions & 23 deletions src/test/system/custom_element_test.js
Original file line number Diff line number Diff line change
Expand Up @@ -442,16 +442,24 @@ testGroup("Custom element API", { template: "editor_empty" }, () => {

test("editor resets to its original value on form reset", async () => {
const element = getEditorElement()
const { form } = element.inputElement
const { form } = element

await typeCharacters("hello")
form.reset()
expectDocument("\n")
})

test("editor sanitizes initial value", async () => {
const element = getEditorElement()

expectDocument("\n")

assert.equal(element.innerHTML, "")
})

test("editor resets to last-set value on form reset", async () => {
const element = getEditorElement()
const { form } = element.inputElement
const { form } = element

element.value = "hi"
await typeCharacters("hello")
Expand All @@ -461,7 +469,7 @@ testGroup("Custom element API", { template: "editor_empty" }, () => {

test("editor respects preventDefault on form reset", async () => {
const element = getEditorElement()
const { form } = element.inputElement
const { form } = element
const preventDefault = (event) => event.preventDefault()

await typeCharacters("hello")
Expand All @@ -475,25 +483,12 @@ testGroup("Custom element API", { template: "editor_empty" }, () => {

testGroup("<label> support", { template: "editor_with_labels" }, () => {
test("associates all label elements", () => {
const labels = [ document.getElementById("label-1"), document.getElementById("label-3") ]
assert.deepEqual(getEditorElement().labels, labels)
})

test("focuses when <label> clicked", () => {
document.getElementById("label-1").click()
assert.equal(getEditorElement(), document.activeElement)
})

test("focuses when <label> descendant clicked", () => {
document.getElementById("label-1").querySelector("span").click()
assert.equal(getEditorElement(), document.activeElement)
})
const element = getEditorElement()
const labels = Array.from(element.labels)
const controls = labels.map((label) => label.control)

test("does not focus when <label> controls another element", () => {
const label = document.getElementById("label-2")
assert.notEqual(getEditorElement(), label.control)
label.click()
assert.notEqual(getEditorElement(), document.activeElement)
assert.deepEqual(labels, [ document.getElementById("label-1"), document.getElementById("label-3") ])
assert.deepEqual(controls, [ element, element ])
})
})

Expand All @@ -505,8 +500,8 @@ testGroup("form property references its <form>", { template: "editors_with_forms
})

test("transitively accesses its related <input> element's <form>", () => {
const form = document.getElementById("input-form")
const editor = document.getElementById("editor-with-input-form")
const form = document.getElementById("attribute-form")
const editor = document.getElementById("editor-with-attribute-form")
assert.equal(editor.form, form)
})

Expand Down
10 changes: 1 addition & 9 deletions src/test/system/installation_process_test.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,28 +21,21 @@ testGroup("Installation process", { template: "editor_html" }, () => {
})

testGroup("Installation process without specified elements", { template: "editor_empty" }, () =>
test("creates identified toolbar and input elements", () => {
test("creates identified toolbar", () => {
const editorElement = getEditorElement()

const toolbarId = editorElement.getAttribute("toolbar")
assert.ok(/trix-toolbar-\d+/.test(toolbarId), `toolbar id not assert.ok ${JSON.stringify(toolbarId)}`)
const toolbarElement = document.getElementById(toolbarId)
assert.ok(toolbarElement, "toolbar element not assert.ok")
assert.equal(editorElement.toolbarElement, toolbarElement)

const inputId = editorElement.getAttribute("input")
assert.ok(/trix-input-\d+/.test(inputId), `input id not assert.ok ${JSON.stringify(inputId)}`)
const inputElement = document.getElementById(inputId)
assert.ok(inputElement, "input element not assert.ok")
assert.equal(editorElement.inputElement, inputElement)
})
)

testGroup("Installation process with specified elements", { template: "editor_with_toolbar_and_input" }, () => {
test("uses specified elements", () => {
const editorElement = getEditorElement()
assert.equal(editorElement.toolbarElement, document.getElementById("my_toolbar"))
assert.equal(editorElement.inputElement, document.getElementById("my_input"))
assert.equal(editorElement.value, "<div>Hello world</div>")
})

Expand All @@ -58,7 +51,6 @@ testGroup("Installation process with specified elements", { template: "editor_wi

const editorElement = getEditorElement()
assert.equal(editorElement.toolbarElement, document.getElementById("my_toolbar"))
assert.equal(editorElement.inputElement, document.getElementById("my_input"))
assert.equal(editorElement.value, "<div>Hello world</div>")
})
})
4 changes: 2 additions & 2 deletions src/test/test_helpers/fixtures/editor_html.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
export default () =>
`<input id="my_input" type="hidden" value="&lt;div&gt;Hello world&lt;/div&gt;">
<trix-editor input="my_input" autofocus placeholder="Say hello..."></trix-editor>`
`<trix-editor input="my_input" autofocus placeholder="Say hello..."><div>Hello world</div></trix-editor>
`
2 changes: 2 additions & 0 deletions src/test/test_helpers/fixtures/editor_unsafe.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export default () => `<trix-editor autofocus><script>alert("unsafe")</script></trix-editor>
`
3 changes: 1 addition & 2 deletions src/test/test_helpers/fixtures/editor_with_image.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { TEST_IMAGE_URL } from "./test_image_url"

export default () =>
`<trix-editor input="my_input" autofocus placeholder="Say hello..."></trix-editor>
<input id="my_input" type="hidden" value="ab&lt;img src=&quot;${TEST_IMAGE_URL}&quot; width=&quot;10&quot; height=&quot;10&quot;&gt;">`
`<trix-editor autofocus placeholder="Say hello...">ab<img src="${TEST_IMAGE_URL}" width="10" height="10"></trix-editor>`
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
export default () =>
`<ul id="my_editor">
<li><trix-toolbar id="my_toolbar"></trix-toolbar></li>
<li><trix-editor toolbar="my_toolbar" input="my_input" autofocus placeholder="Say hello..."></trix-editor></li>
<li><input id="my_input" type="hidden" value="&lt;div&gt;Hello world&lt;/div&gt;"></li>
<li><trix-editor toolbar="my_toolbar" autofocus placeholder="Say hello..."><div>Hello world</div></trix-editor></li>
</ul>`
6 changes: 2 additions & 4 deletions src/test/test_helpers/fixtures/editors_with_forms.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,7 @@ export default () =>
<trix-editor id="editor-with-ancestor-form"></trix-editor>
</form>
<form id="input-form">
<input type="hidden" id="hidden-input">
</form>
<trix-editor id="editor-with-input-form" input="hidden-input"></trix-editor>
<form id="attribute-form"></form>
<trix-editor id="editor-with-attribute-form" form="attribute-form"></trix-editor>
<trix-editor id="editor-with-no-form"></trix-editor>`
2 changes: 1 addition & 1 deletion src/trix/controllers/editor_controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -503,7 +503,7 @@ export default class EditorController extends Controller {
updateInputElement() {
const element = this.compositionController.getSerializableElement()
const value = serializeToContentType(element, "text/html")
return this.editorElement.setInputElementValue(value)
return this.editorElement.setFormValue(value)
}

notifyEditorElement(message, data) {
Expand Down
97 changes: 27 additions & 70 deletions src/trix/elements/trix_editor_element.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import * as config from "trix/config"

import {
findClosestElementFromNode,
handleEvent,
handleEventOnce,
installDefaultCSSForTagName,
Expand Down Expand Up @@ -161,6 +160,15 @@ installDefaultCSSForTagName("trix-editor", `\
}`)

export default class TrixEditorElement extends HTMLElement {
static formAssociated = true

#internals
#value

constructor() {
super()
this.#internals = this.attachInternals()
}

// Properties

Expand All @@ -174,19 +182,7 @@ export default class TrixEditorElement extends HTMLElement {
}

get labels() {
const labels = []
if (this.id && this.ownerDocument) {
labels.push(...Array.from(this.ownerDocument.querySelectorAll(`label[for='${this.id}']`) || []))
}

const label = findClosestElementFromNode(this, { matchingSelector: "label" })
if (label) {
if ([ this, null ].includes(label.control)) {
labels.push(label)
}
}

return labels
return this.#internals.labels
}

get toolbarElement() {
Expand All @@ -204,33 +200,23 @@ export default class TrixEditorElement extends HTMLElement {
}

get form() {
return this.inputElement?.form
}

get inputElement() {
if (this.hasAttribute("input")) {
return this.ownerDocument?.getElementById(this.getAttribute("input"))
} else if (this.parentNode) {
const inputId = `trix-input-${this.trixId}`
this.setAttribute("input", inputId)
const element = makeElement("input", { type: "hidden", id: inputId })
this.parentNode.insertBefore(element, this.nextElementSibling)
return element
} else {
return undefined
}
return this.#internals.form
}

get editor() {
return this.editorController?.editor
}

get type() {
return this.localName
}

get name() {
return this.inputElement?.name
return this.getAttribute("name")
}

get value() {
return this.inputElement?.value
return this.#value
}

set value(defaultValue) {
Expand All @@ -246,10 +232,9 @@ export default class TrixEditorElement extends HTMLElement {
}
}

setInputElementValue(value) {
if (this.inputElement) {
this.inputElement.value = value
}
setFormValue(value) {
this.#value = value
this.#internals.setFormValue(value)
}

// Element lifecycle
Expand All @@ -264,62 +249,34 @@ export default class TrixEditorElement extends HTMLElement {
triggerEvent("trix-before-initialize", { onElement: this })
this.editorController = new EditorController({
editorElement: this,
html: this.defaultValue = this.value,
html: this.defaultValue = this.innerHTML,
})
requestAnimationFrame(() => triggerEvent("trix-initialize", { onElement: this }))
}
this.editorController.registerSelectionManager()
this.registerResetListener()
this.registerClickListener()
autofocus(this)
}
}

disconnectedCallback() {
this.editorController?.unregisterSelectionManager()
this.unregisterResetListener()
return this.unregisterClickListener()
}

// Form support
// ElementInternals lifecycle

registerResetListener() {
this.resetListener = this.resetBubbled.bind(this)
return window.addEventListener("reset", this.resetListener, false)
}
formAssociatedCallback(form) {

unregisterResetListener() {
return window.removeEventListener("reset", this.resetListener, false)
}

registerClickListener() {
this.clickListener = this.clickBubbled.bind(this)
return window.addEventListener("click", this.clickListener, false)
}
formDisabledCallback(disabled) {

unregisterClickListener() {
return window.removeEventListener("click", this.clickListener, false)
}

resetBubbled(event) {
if (event.defaultPrevented) return
if (event.target !== this.form) return
return this.reset()
formResetCallback() {
this.value = this.defaultValue
}

clickBubbled(event) {
if (event.defaultPrevented) return
if (this.contains(event.target)) return

const label = findClosestElementFromNode(event.target, { matchingSelector: "label" })
if (!label) return

if (!Array.from(this.labels).includes(label)) return
formStateRestoreCallback(state, mode) {

return this.focus()
}

reset() {
this.value = this.defaultValue
}
}

0 comments on commit e1ad3a8

Please sign in to comment.