Skip to content

Commit

Permalink
Extract delegate for TrixEditorElement
Browse files Browse the repository at this point in the history
In preparation for [#1128][], this commit introduces a module-private
`Delegate` class to serve as a representation of what form integration
requires for the `<trix-editor>` custom element. The structure of the
`Delegate` class mirrors that of the `TrixEditorElement` from which its
contents are extracted.

First, there are the properties that mimic those of most form controls,
including:

* `labels`
* `form`
* `name`
* `value`
* `defaultValue`
* `type`

With the exception of `labels`, property access is mostly proxied
through the associated `<input type="hidden">` element (accessed through
its own `inputElement` property).

Next, the `Delegate` defines methods that correspond to the Custom
Element lifecycle events, including:

* `connectedCallback`
* `disconnectedCallback`
* `setFormValue`

The connected and disconnected callbacks mirror that of the
`TrixEditorElement` itself. These callbacks attach and remove event
listeners for `click` and `reset` events.

The `setFormValue` is named to correspond with
[ElementInternals.setFormValue][]. Along with introducing this callback
method, this commit renames the `TrixEditorElement.setInputElementValue`
method to `TrixEditorElement.setFormValue`.

In addition to renaming `setInputElementValue`, this commit also defines
`TrixEditorElement.formResetCallback` (along with other empty
callbacks), then implements `TrixEditorElement.reset` as an alias. The
name mirrors the [ElementInternals.formResetCallback][].

[#1128]: #1128
[ElementInternals.setFormValue]: https://developer.mozilla.org/en-US/docs/Web/API/ElementInternals/setFormValue
[ElementInternals.formResetCallback]: https://web.dev/articles/more-capable-form-controls#void_formresetcallback
  • Loading branch information
seanpdoyle committed Feb 7, 2024
1 parent 3f22606 commit 3cacb0b
Show file tree
Hide file tree
Showing 3 changed files with 157 additions and 69 deletions.
36 changes: 33 additions & 3 deletions src/test/system/custom_element_test.js
Original file line number Diff line number Diff line change
Expand Up @@ -391,6 +391,12 @@ testGroup("Custom element API", { template: "editor_empty" }, () => {
})
})

test("element returns empty string when value is missing", async () => {
const element = getEditorElement()

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

test("element serializes HTML after attribute changes", async () => {
const element = getEditorElement()
let serializedHTML = element.value
Expand Down Expand Up @@ -440,9 +446,17 @@ testGroup("Custom element API", { template: "editor_empty" }, () => {
return promise
})

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

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

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()
Expand All @@ -451,7 +465,7 @@ testGroup("Custom element API", { template: "editor_empty" }, () => {

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 +475,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 @@ -473,6 +487,16 @@ testGroup("Custom element API", { template: "editor_empty" }, () => {
})
})

testGroup("HTML sanitization", { template: "editor_html" }, () => {
test("ignores text nodes in script elements", () => {
const element = getEditorElement()
element.value = "<div>safe</div><script>alert(\"unsafe\")</script>"

assert.equal(element.innerHTML, "<div><!--block-->safe</div>")
assert.equal(element.value, "<div>safe</div>")
})
})

testGroup("<label> support", { template: "editor_with_labels" }, () => {
test("associates all label elements", () => {
const labels = [ document.getElementById("label-1"), document.getElementById("label-3") ]
Expand Down Expand Up @@ -514,4 +538,10 @@ testGroup("form property references its <form>", { template: "editors_with_forms
const editor = document.getElementById("editor-with-no-form")
assert.equal(editor.form, null)
})

test("editor returns its type", async() => {
const element = getEditorElement()

assert.equal("trix-editor", element.type)
})
})
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
188 changes: 123 additions & 65 deletions src/trix/elements/trix_editor_element.js
Original file line number Diff line number Diff line change
Expand Up @@ -160,7 +160,110 @@ installDefaultCSSForTagName("trix-editor", `\
margin-right: -1px !important;
}`)

class Delegate {
#element

constructor(element) {
this.#element = element
}

// Properties

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

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

return labels
}

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

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

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

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

get defaultValue() {
return this.value
}

// Element lifecycle

connectedCallback() {
window.addEventListener("reset", this.#resetBubbled, false)
window.addEventListener("click", this.#clickBubbled, false)
}

disconnectedCallback() {
window.removeEventListener("reset", this.#resetBubbled, false)
window.removeEventListener("click", this.#clickBubbled, false)
}

setFormValue(value) {
if (this.inputElement) {
this.inputElement.value = value
}
}

// Form support

#resetBubbled = (event) => {
if (event.defaultPrevented) return
if (event.target !== this.form) return
return this.#element.formResetCallback()
}

#clickBubbled = (event) => {
if (event.defaultPrevented) return
if (this.#element.contains(event.target)) return

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

if (!Array.from(this.labels).includes(label)) return

return this.#element.focus()
}
}

export default class TrixEditorElement extends HTMLElement {
static delegateClass = Delegate
static formAssociated = false

#delegate

constructor() {
super()
this.#delegate = new this.constructor.delegateClass(this)
}

// Properties

Expand All @@ -174,19 +277,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.#delegate.labels
}

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

get form() {
return this.inputElement?.form
return this.#delegate.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.#delegate.inputElement
}

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

get name() {
return this.inputElement?.name
return this.#delegate.name
}

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

get type() {
return this.localName
}

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

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

// Element lifecycle
Expand All @@ -264,62 +347,37 @@ 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.#delegate.defaultValue,
})
requestAnimationFrame(() => triggerEvent("trix-initialize", { onElement: this }))
}
this.editorController.registerSelectionManager()
this.registerResetListener()
this.registerClickListener()
this.#delegate.connectedCallback()
autofocus(this)
}
}

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

// Form support

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

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

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

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

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

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

return this.focus()
formResetCallback() {
this.value = this.defaultValue
}

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

0 comments on commit 3cacb0b

Please sign in to comment.