From f927b8031972c18f7fd51c36fd38e3aca3c1c16c Mon Sep 17 00:00:00 2001 From: Sean Doyle Date: Wed, 7 Feb 2024 12:20:07 -0500 Subject: [PATCH] Extract delegate for `TrixEditorElement` In preparation for [#1128][], this commit introduces a module-private `Delegate` class to serve as a representation of what form integration requires for the `` 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 `` 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][]. [#1128]: https://github.com/basecamp/trix/pull/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 --- src/test/system/custom_element_test.js | 20 ++- src/trix/controllers/editor_controller.js | 2 +- src/trix/elements/trix_editor_element.js | 191 ++++++++++++++-------- 3 files changed, 138 insertions(+), 75 deletions(-) diff --git a/src/test/system/custom_element_test.js b/src/test/system/custom_element_test.js index 2e77e3fc4..8eec7d3de 100644 --- a/src/test/system/custom_element_test.js +++ b/src/test/system/custom_element_test.js @@ -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() @@ -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") @@ -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") @@ -514,4 +522,10 @@ testGroup("form property references its
", { 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) + }) }) diff --git a/src/trix/controllers/editor_controller.js b/src/trix/controllers/editor_controller.js index 6f469db5a..04c2e921d 100644 --- a/src/trix/controllers/editor_controller.js +++ b/src/trix/controllers/editor_controller.js @@ -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) { diff --git a/src/trix/elements/trix_editor_element.js b/src/trix/elements/trix_editor_element.js index 33877cc1a..dff0cbcbc 100644 --- a/src/trix/elements/trix_editor_element.js +++ b/src/trix/elements/trix_editor_element.js @@ -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 @@ -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() { @@ -204,21 +295,11 @@ 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() { @@ -226,11 +307,15 @@ export default class TrixEditorElement extends HTMLElement { } 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) { @@ -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 @@ -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() } }