Skip to content

Commit

Permalink
Merge pull request #1190 from seanpdoyle/validate-required
Browse files Browse the repository at this point in the history
Support for `ElementInternals` and Constraint Validations
  • Loading branch information
jorgemanrubia authored Oct 13, 2024
2 parents b2cefb4 + 1cfa094 commit 5c05d83
Show file tree
Hide file tree
Showing 9 changed files with 545 additions and 59 deletions.
132 changes: 131 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ This is the approach that all modern, production ready, WYSIWYG editors now take

<details><summary>Trix supports all evergreen, self-updating desktop and mobile browsers.</summary><img src="https://app.saucelabs.com/browser-matrix/basecamp_trix.svg"></details>

Trix is built with established web standards, notably [Custom Elements](https://developer.mozilla.org/en-US/docs/Web/Web_Components/Using_custom_elements), [Mutation Observer](https://dom.spec.whatwg.org/#mutation-observers), and [Promises](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise).
Trix is built with established web standards, notably [Custom Elements](https://developer.mozilla.org/en-US/docs/Web/Web_Components/Using_custom_elements), [Element Internals](https://developer.mozilla.org/en-US/docs/Web/API/ElementInternals), [Mutation Observer](https://dom.spec.whatwg.org/#mutation-observers), and [Promises](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise).

# Getting Started

Expand Down Expand Up @@ -88,6 +88,16 @@ If the attribute is defined in `Trix.config.blockAttributes`, Trix will apply th

Clicking the quote button toggles whether the block should be rendered with `<blockquote>`.

## Integrating with Element Internals

Trix will integrate `<trix-editor>` elements with forms depending on the browser's support for [Element Internals](https://developer.mozilla.org/en-US/docs/Web/API/ElementInternals). If there is a need to disable support for `ElementInternals`, set `Trix.elements.TrixEditorElement.formAssociated = false`:

```js
import Trix from "trix"

Trix.elements.TrixEditorElement.formAssociated = false
```

## Invoking Internal Trix Actions

Internal actions are defined in `controllers/editor_controller.js` and consist of:
Expand Down Expand Up @@ -148,6 +158,126 @@ To populate a `<trix-editor>` 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 `<trix-editor>…</trix-editor>` tag.

## Validating the Editor

Out of the box, `<trix-editor>` 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
<input id="x" value="" type="hidden" name="content">
<trix-editor input="x" required></trix-editor>
```

Since the `<trix-editor>` 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, `<trix-editor>`
elements support custom validation through their [setCustomValidity][] method.
For example, consider the following HTML:

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

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
return true
}

if (isValid(trixDocument)) {
editorElement.setCustomValidity("")
} else {
editorElement.setCustomValidity("The document is not valid.")
}
}
```
[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 `<trix-editor>`, render it with the `[disabled]` attribute:
```html
<trix-editor disabled></trix-editor>
```
Disabled editors are not editable, cannot receive focus, and their values will
be ignored when their related `<form>` element is submitted.
To change whether or not an editor is disabled, either toggle the `[disabled]`
attribute or assign a boolean to the `.disabled` property:
```html
<trix-editor id="editor" disabled></trix-editor>

<script>
const editor = document.getElementById("editor")

editor.toggleAttribute("disabled", false)
editor.disabled = true
</script>
```
When disabled, the editor will match the [:disabled CSS
pseudo-class][:disabled].
[:disabled]: https://developer.mozilla.org/en-US/docs/Web/CSS/:disabled
## Providing an Accessible Name
Like other form controls, `<trix-editor>` elements should have an accessible name. The `<trix-editor>` element integrates with `<label>` elements and The `<trix-editor>` supports two styles of integrating with `<label>` elements:
1. render the `<trix-editor>` element with an `[id]` attribute that the `<label>` element references through its `[for]` attribute:
```html
<label for="editor">Editor</label>
<trix-editor id="editor"></trix-editor>
```
2. render the `<trix-editor>` element as a child of the `<label>` element:
```html
<trix-toolbar id="editor-toolbar"></trix-toolbar>
<label>
Editor

<trix-editor toolbar="editor-toolbar"></trix-editor>
</label>
```
> [!WARNING]
> When rendering the `<trix-editor>` element as a child of the `<label>` element, [explicitly render](#creating-an-editor) the corresponding `<trix-toolbar>` element outside of the `<label>` element.
In addition to integrating with `<label>` elements, `<trix-editor>` elements support `[aria-label]` and `[aria-labelledby]` attributes.
## Styling Formatted Content
To ensure what you see when you edit is what you see when you save, use a CSS class name to scope styles for Trix formatted content. Apply this class name to your `<trix-editor>` element, and to a containing element when you render stored Trix content for display in your application.
Expand Down
4 changes: 4 additions & 0 deletions assets/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@
max-width: 700px;
}

trix-editor:invalid {
border: solid 1px red;
}

#output {
margin: 1rem 0 0;
}
Expand Down
13 changes: 7 additions & 6 deletions src/test/system/accessibility_test.js
Original file line number Diff line number Diff line change
@@ -1,33 +1,34 @@
import { assert, test, testGroup, triggerEvent } from "test/test_helper"
import { assert, skipIf, test, testGroup, triggerEvent } from "test/test_helper"
import TrixEditorElement from "trix/elements/trix_editor_element"

testGroup("Accessibility attributes", { template: "editor_default_aria_label" }, () => {
test("sets the role to textbox", () => {
const editor = document.getElementById("editor-without-labels")
assert.equal(editor.getAttribute("role"), "textbox")
})

test("does not set aria-label when the element has no <label> elements", () => {
skipIf(TrixEditorElement.formAssociated, "does not set aria-label when the element has no <label> elements", () => {
const editor = document.getElementById("editor-without-labels")
assert.equal(editor.hasAttribute("aria-label"), false)
})

test("does not override aria-label when the element declares it", () => {
skipIf(TrixEditorElement.formAssociated, "does not override aria-label when the element declares it", () => {
const editor = document.getElementById("editor-with-aria-label")
assert.equal(editor.getAttribute("aria-label"), "ARIA Label text")
})

test("does not set aria-label when the element declares aria-labelledby", () => {
skipIf(TrixEditorElement.formAssociated, "does not set aria-label when the element declares aria-labelledby", () => {
const editor = document.getElementById("editor-with-aria-labelledby")
assert.equal(editor.hasAttribute("aria-label"), false)
assert.equal(editor.getAttribute("aria-labelledby"), "aria-labelledby-id")
})

test("assigns aria-label to the text of the element's <label> elements", () => {
skipIf(TrixEditorElement.formAssociated, "assigns aria-label to the text of the element's <label> elements", () => {
const editor = document.getElementById("editor-with-labels")
assert.equal(editor.getAttribute("aria-label"), "Label 1 Label 2 Label 3")
})

test("updates the aria-label on focus", () => {
skipIf(TrixEditorElement.formAssociated, "updates the aria-label on focus", () => {
const editor = document.getElementById("editor-with-modified-label")
const label = document.getElementById("modified-label")

Expand Down
150 changes: 149 additions & 1 deletion src/test/system/custom_element_test.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { rangesAreEqual } from "trix/core/helpers"
import TrixEditorElement from "trix/elements/trix_editor_element"

import {
TEST_IMAGE_URL,
Expand Down Expand Up @@ -500,7 +501,7 @@ 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)
assert.deepEqual(Array.from(getEditorElement().labels), labels)
})

test("focuses when <label> clicked", () => {
Expand Down Expand Up @@ -538,4 +539,151 @@ 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 resets to its original value on element reset", async () => {
const element = getEditorElement()

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

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

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

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

assert.equal("trix-editor", element.type)
})

testIf(TrixEditorElement.formAssociated, "adds [disabled] attribute based on .disabled property", () => {
const editor = document.getElementById("editor-with-ancestor-form")

editor.disabled = true

assert.equal(editor.hasAttribute("disabled"), true, "adds [disabled] attribute")

editor.disabled = false

assert.equal(editor.hasAttribute("disabled"), false, "removes [disabled] attribute")
})

testIf(TrixEditorElement.formAssociated, "removes [contenteditable] and disables input when editor element has [disabled]", () => {
const editor = document.getElementById("editor-with-no-form")

editor.setAttribute("disabled", "")

assert.equal(editor.matches(":disabled"), true, "sets :disabled CSS pseudostate")
assert.equal(editor.inputElement.disabled, true, "disables input")
assert.equal(editor.disabled, true, "exposes [disabled] attribute as .disabled property")
assert.equal(editor.hasAttribute("contenteditable"), false, "removes [contenteditable] attribute")

editor.removeAttribute("disabled")

assert.equal(editor.matches(":disabled"), false, "removes sets :disabled pseudostate")
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")
})

testIf(TrixEditorElement.formAssociated, "removes [contenteditable] and disables input when editor element is :disabled", () => {
const editor = document.getElementById("editor-within-fieldset")
const fieldset = document.getElementById("fieldset")

fieldset.disabled = true

assert.equal(editor.matches(":disabled"), true, "sets :disabled CSS pseudostate")
assert.equal(editor.inputElement.disabled, true, "disables input")
assert.equal(editor.disabled, true, "infers disabled state from ancestor")
assert.equal(editor.hasAttribute("disabled"), false, "does not set [disabled] attribute")
assert.equal(editor.hasAttribute("contenteditable"), false, "removes [contenteditable] attribute")

fieldset.disabled = false

assert.equal(editor.matches(":disabled"), false, "removes sets :disabled pseudostate")
assert.equal(editor.inputElement.disabled, false, "enabled input")
assert.equal(editor.disabled, false, "updates .disabled property")
assert.equal(editor.hasAttribute("disabled"), false, "does not set [disabled] attribute")
assert.equal(editor.hasAttribute("contenteditable"), true, "adds [contenteditable] attribute")
})

testIf(TrixEditorElement.formAssociated, "does not receive focus when :disabled", () => {
const activeEditor = document.getElementById("editor-with-input-form")
const editor = document.getElementById("editor-within-fieldset")

activeEditor.focus()
editor.disabled = true
editor.focus()

assert.equal(activeEditor, document.activeElement, "disabled editor does not receive focus")
})

testIf(TrixEditorElement.formAssociated, "disabled editor does not encode its value when the form is submitted", () => {
const editor = document.getElementById("editor-with-ancestor-form")
const form = editor.form

editor.inputElement.value = "Hello world"
editor.disabled = true

assert.deepEqual({}, Object.fromEntries(new FormData(form).entries()), "does not write to FormData")
})

testIf(TrixEditorElement.formAssociated, "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")
})

testIf(TrixEditorElement.formAssociated, "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")
})

testIf(TrixEditorElement.formAssociated, "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")
})

testIf(TrixEditorElement.formAssociated, "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")
})
})
8 changes: 3 additions & 5 deletions src/test/test_helpers/fixtures/editor_with_labels.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
export default () =>
`<label id="label-1" for="editor"><span>Label 1</span></label>
<label id="label-2">
Label 2
<trix-editor id="editor"></trix-editor>
</label>
<label id="label-3" for="editor">Label 3</label>`
<label id="label-2">Label 2</label>
<trix-editor id="editor"></trix-editor>
<label id="label-3" for="editor">Label 3</label>`
5 changes: 3 additions & 2 deletions src/test/test_helpers/fixtures/editors_with_forms.js
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
export default () =>
`<form id="ancestor-form">
<trix-editor id="editor-with-ancestor-form"></trix-editor>
<trix-editor id="editor-with-ancestor-form" name="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>
<trix-editor id="editor-with-no-form"></trix-editor>`
<trix-editor id="editor-with-no-form"></trix-editor>
<fieldset id="fieldset"><trix-editor id="editor-within-fieldset"></fieldset>`
Loading

0 comments on commit 5c05d83

Please sign in to comment.