Skip to content

Commit

Permalink
Extract delegate for TrixEditorElement
Browse files Browse the repository at this point in the history
In preparation for [basecamp#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`, then implements
`TrixEditorElement.reset` as an alias. The name mirrors the
[ElementInternals.formResetCallback][].

[basecamp#1128]: basecamp#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 f927b80
Show file tree
Hide file tree
Showing 3 changed files with 138 additions and 75 deletions.
20 changes: 17 additions & 3 deletions src/test/system/custom_element_test.js
Original file line number Diff line number Diff line change
Expand Up @@ -440,9 +440,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 +459,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 +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 Down Expand Up @@ -514,4 +522,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
191 changes: 120 additions & 71 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,28 @@ 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)
}

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

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

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 f927b80

Please sign in to comment.