diff --git a/README.md b/README.md index b21d680d3..8ce447827 100644 --- a/README.md +++ b/README.md @@ -148,6 +148,68 @@ To populate a `` with stored content, include that content in the a Always use an associated input element to safely populate an editor. Trix won’t load any HTML content inside a `` tag. +## Validating the Editor + +Out of the box, `` elements support browsers' built-in [Constraint +validation][]. When rendered with the [required][] attribute, editors will be +invalid when they're completely empty. For example, consider the following HTML: + +```html + + +``` + +Since the `` element is `[required]`, it is invalid when its value +is empty: + +```js +const editor = document.querySelector("trix-editor") + +editor.validity.valid // => false +editor.validity.valueMissing // => true +editor.matches(":valid") // => false +editor.matches(":invalid") // => true + +editor.value = "A value that isn't empty" + +editor.validity.valid // => true +editor.validity.valueMissing // => false +editor.matches(":valid") // => true +editor.matches(":invalid") // => false +``` + +In addition to the built-in `[required]` attribute, `` +elements support custom validation through their [setCustomValidity][] method. +For example, consider the following HTML: + +```js + + +``` + +Custom validation can occur at any time. For example, validation can occur after +a `trix-change` event fired after the editor's contents change: + +```js +addEventListener("trix-change", (event) => { + const editorElement = event.target + const trixDocument = editorElement.editor.getDocument() + const isValid = (trixDocument) => { + // determine the validity based on your custom criteria + } + + if (isValid(trixDocument)) { + editorElement.setCustomValidity("The document is not valid.") + } else { + editorElement.setCustomValidity("") + } +} +``` + +[Constraint validation]: https://developer.mozilla.org/en-US/docs/Web/HTML/Constraint_validation +[required]: https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/required +[setCustomValidity]: https://developer.mozilla.org/en-US/docs/Web/API/HTMLObjectElement/setCustomValidity + ## Disabling the Editor To disable the ``, render it with the `[disabled]` attribute: diff --git a/assets/index.html b/assets/index.html index e4cf73b85..65e97b39b 100644 --- a/assets/index.html +++ b/assets/index.html @@ -17,6 +17,10 @@ max-width: 700px; } + trix-editor:invalid { + border: solid 1px red; + } + #output { margin: 1rem 0 0; } diff --git a/src/test/system/custom_element_test.js b/src/test/system/custom_element_test.js index 1f91473d8..f5ea2b359 100644 --- a/src/test/system/custom_element_test.js +++ b/src/test/system/custom_element_test.js @@ -587,6 +587,7 @@ testGroup("integrates with its
", { template: "editors_with_forms", contai assert.equal(editor.inputElement.disabled, false, "enabled input") assert.equal(editor.disabled, false, "updates .disabled property") assert.equal(editor.hasAttribute("contenteditable"), true, "adds [contenteditable] attribute") + :xa }) test("removes [contenteditable] and disables input when editor element is :disabled", () => { @@ -630,4 +631,59 @@ testGroup("integrates with its ", { template: "editors_with_forms", contai assert.deepEqual({}, Object.fromEntries(new FormData(form).entries()), "does not write to FormData") }) + + test("validates with [required] attribute as invalid", () => { + const editor = document.getElementById("editor-with-ancestor-form") + const form = editor.form + let invalidEvent, submitEvent = null + + editor.addEventListener("invalid", event => invalidEvent = event, { once: true }) + form.addEventListener("submit", event => submitEvent = event, { once: true }) + + editor.required = true + form.requestSubmit() + + // assert.equal(document.activeElement, editor, "editor receives focus") + assert.equal(editor.required, true, ".required property retrurns true") + assert.equal(editor.validity.valid, false, "validity.valid is false") + assert.equal(editor.validationMessage, "Please fill out this field.", "sets .validationMessage") + assert.equal(invalidEvent.target, editor, "dispatches 'invalid' event on editor") + assert.equal(submitEvent, null, "does not dispatch a 'submit' event") + }) + + test("does not validate with [disabled] attribute", () => { + const editor = document.getElementById("editor-with-ancestor-form") + let invalidEvent = null + + editor.disabled = true + editor.required = true + editor.addEventListener("invalid", event => invalidEvent = event, { once: true }) + editor.reportValidity() + + assert.equal(invalidEvent, null, "does not dispatch an 'invalid' event") + }) + + test("re-validates when the value changes", async () => { + const editor = document.getElementById("editor-with-ancestor-form") + editor.required = true + editor.focus() + + assert.equal(editor.validity.valid, false, "validity.valid is initially false") + + await typeCharacters("a") + + assert.equal(editor.validity.valid, true, "validity.valid is true after re-validating") + assert.equal(editor.validity.valueMissing, false, "validity.valueMissing is false") + assert.equal(editor.validationMessage, "", "clears the validationMessage") + }) + + test("accepts a customError validation message", () => { + const editor = document.getElementById("editor-with-ancestor-form") + + editor.setCustomValidity("A custom validation message") + + assert.equal(editor.validity.valid, false) + assert.equal(editor.validity.customError, true) + assert.equal(editor.validationMessage, "A custom validation message") + }) }) diff --git a/src/trix/elements/trix_editor_element.js b/src/trix/elements/trix_editor_element.js index f643ff109..b3b31e5f7 100644 --- a/src/trix/elements/trix_editor_element.js +++ b/src/trix/elements/trix_editor_element.js @@ -162,6 +162,7 @@ installDefaultCSSForTagName("trix-editor", `\ export default class TrixEditorElement extends HTMLElement { static formAssociated = true + #customValidationMessage #internals constructor() { @@ -257,6 +258,8 @@ export default class TrixEditorElement extends HTMLElement { if (this.inputElement) { this.inputElement.value = value } + + this.#synchronizeValidation() } // Element lifecycle @@ -276,6 +279,7 @@ export default class TrixEditorElement extends HTMLElement { requestAnimationFrame(() => triggerEvent("trix-initialize", { onElement: this })) } this.editorController.registerSelectionManager() + this.#synchronizeValidation() autofocus(this) } } @@ -284,6 +288,43 @@ export default class TrixEditorElement extends HTMLElement { this.editorController?.unregisterSelectionManager() } + // Constraint validation + + set required(value) { + this.toggleAttribute("required", value) + this.#synchronizeValidation() + } + + get required() { + return this.hasAttribute("required") + } + + get validity() { + return this.#internals.validity + } + + get validationMessage() { + return this.#internals.validationMessage + } + + get willValidate() { + return this.#internals.willValidate + } + + checkValidity() { + return this.#internals.checkValidity() + } + + reportValidity() { + return this.#internals.reportValidity() + } + + setCustomValidity(customValidationMessage) { + this.#customValidationMessage = customValidationMessage + + this.#synchronizeValidation() + } + // Form support formDisabledCallback(disabled) { @@ -298,4 +339,14 @@ export default class TrixEditorElement extends HTMLElement { reset() { this.value = this.defaultValue } + + #synchronizeValidation() { + const { required, value } = this + const valueMissing = required && !value + const customError = !!this.#customValidationMessage + const input = Object.assign(document.createElement("input"), { required }) + const validationMessage = this.#customValidationMessage || input.validationMessage + + this.#internals.setValidity({ valueMissing, customError }, validationMessage) + } }