Skip to content

Commit 5806006

Browse files
committed
Support for ElementInternals and Constraint Validations
Integrate with `<form>` elements directly through built-in support for [ElementInternals][]. This is achieved with graceful degradation in two ways: * automatically check for browser support * support globally disabling through `Trix.config.editor.formAssociated = false` According to the [Form-associated custom elements][] section of [More capable form controls][], various behaviors that the `<trix-editor>` element was recreating are provided out of the box. For example, the `<label>` element support can be achieved through [ElementInternals.labels][]. Similarly, a `formResetCallback()` will fire whenever the associated `<form>` element resets. Add support for integrating with [Constraint validation][] through the support for the `[required]` attribute and the `setCustomValidity(message)` method. [Constraint validation]: https://developer.mozilla.org/en-US/docs/Web/HTML/Constraint_validation [#1023]: #1023 [ElementInternals]: https://developer.mozilla.org/en-US/docs/Web/API/ElementInternals [Form-associated custom elements]: https://web.dev/articles/more-capable-form-controls#form-associated_custom_elements [More capable form controls]: https://web.dev/articles/more-capable-form-controls [ElementInternals.setFormValue]: https://developer.mozilla.org/en-US/docs/Web/API/ElementInternals/setFormValue [ElementInternals.labels]: https://developer.mozilla.org/en-US/docs/Web/API/ElementInternals/labels
1 parent 2b7f980 commit 5806006

File tree

12 files changed

+473
-17
lines changed

12 files changed

+473
-17
lines changed

.github/workflows/ci.yml

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,12 @@ on:
88

99
jobs:
1010
build:
11-
name: Browser tests
11+
name: "Browser tests (Trix.config.editor.formAssociated = ${{ matrix.formAssociated }})"
12+
strategy:
13+
matrix:
14+
formAssociated: [true, false]
15+
env:
16+
FORM_ASSOCIATED: "${{ matrix.formAssociated }}"
1217
runs-on: ubuntu-latest
1318
steps:
1419
- uses: actions/checkout@v3

README.md

Lines changed: 131 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ This is the approach that all modern, production ready, WYSIWYG editors now take
1919

2020
<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>
2121

22-
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).
22+
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).
2323

2424
# Getting Started
2525

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

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

91+
## Integrating with Element Internals
92+
93+
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). By default, Trix will enable support for `ElementInternals` when the feature is enabled in the browser. If there is a need to disable support for `ElementInternals`, set `Trix.config.editor.formAssociated = false`:
94+
95+
```js
96+
import Trix from "trix"
97+
98+
Trix.config.editor.formAssociated = false
99+
```
100+
91101
## Invoking Internal Trix Actions
92102

93103
Internal actions are defined in `controllers/editor_controller.js` and consist of:
@@ -148,6 +158,126 @@ To populate a `<trix-editor>` with stored content, include that content in the a
148158

149159
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.
150160

161+
## Validating the Editor
162+
163+
Out of the box, `<trix-editor>` elements support browsers' built-in [Constraint
164+
validation][]. When rendered with the [required][] attribute, editors will be
165+
invalid when they're completely empty. For example, consider the following HTML:
166+
167+
```html
168+
<input id="x" value="" type="hidden" name="content">
169+
<trix-editor input="x" required></trix-editor>
170+
```
171+
172+
Since the `<trix-editor>` element is `[required]`, it is invalid when its value
173+
is empty:
174+
175+
```js
176+
const editor = document.querySelector("trix-editor")
177+
178+
editor.validity.valid // => false
179+
editor.validity.valueMissing // => true
180+
editor.matches(":valid") // => false
181+
editor.matches(":invalid") // => true
182+
183+
editor.value = "A value that isn't empty"
184+
185+
editor.validity.valid // => true
186+
editor.validity.valueMissing // => false
187+
editor.matches(":valid") // => true
188+
editor.matches(":invalid") // => false
189+
```
190+
191+
In addition to the built-in `[required]` attribute, `<trix-editor>`
192+
elements support custom validation through their [setCustomValidity][] method.
193+
For example, consider the following HTML:
194+
195+
```js
196+
<input id="x" value="" type="hidden" name="content">
197+
<trix-editor input="x"></trix-editor>
198+
```
199+
200+
Custom validation can occur at any time. For example, validation can occur after
201+
a `trix-change` event fired after the editor's contents change:
202+
203+
```js
204+
addEventListener("trix-change", (event) => {
205+
const editorElement = event.target
206+
const trixDocument = editorElement.editor.getDocument()
207+
const isValid = (trixDocument) => {
208+
// determine the validity based on your custom criteria
209+
return true
210+
}
211+
212+
if (isValid(trixDocument)) {
213+
editorElement.setCustomValidity("")
214+
} else {
215+
editorElement.setCustomValidity("The document is not valid.")
216+
}
217+
}
218+
```
219+
220+
[Constraint validation]: https://developer.mozilla.org/en-US/docs/Web/HTML/Constraint_validation
221+
[required]: https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/required
222+
[setCustomValidity]: https://developer.mozilla.org/en-US/docs/Web/API/HTMLObjectElement/setCustomValidity
223+
224+
## Disabling the Editor
225+
226+
To disable the `<trix-editor>`, render it with the `[disabled]` attribute:
227+
228+
```html
229+
<trix-editor disabled></trix-editor>
230+
```
231+
232+
Disabled editors are not editable, cannot receive focus, and their values will
233+
be ignored when their related `<form>` element is submitted.
234+
235+
To change whether or not an editor is disabled, either toggle the `[disabled]`
236+
attribute or assign a boolean to the `.disabled` property:
237+
238+
```html
239+
<trix-editor id="editor" disabled></trix-editor>
240+
241+
<script>
242+
const editor = document.getElementById("editor")
243+
244+
editor.toggleAttribute("disabled", false)
245+
editor.disabled = true
246+
</script>
247+
```
248+
249+
When disabled, the editor will match the [:disabled CSS
250+
pseudo-class][:disabled].
251+
252+
[:disabled]: https://developer.mozilla.org/en-US/docs/Web/CSS/:disabled
253+
254+
## Providing an Accessible Name
255+
256+
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:
257+
258+
1. render the `<trix-editor>` element with an `[id]` attribute that the `<label>` element references through its `[for]` attribute:
259+
260+
```html
261+
<label for="editor">Editor</label>
262+
<trix-editor id="editor"></trix-editor>
263+
```
264+
265+
2. render the `<trix-editor>` element as a child of the `<label>` element:
266+
267+
```html
268+
<trix-toolbar id="editor-toolbar"></trix-toolbar>
269+
<label>
270+
Editor
271+
272+
<trix-editor toolbar="editor-toolbar"></trix-editor>
273+
</label>
274+
```
275+
276+
> [!WARNING]
277+
> 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.
278+
279+
In addition to integrating with `<label>` elements, `<trix-editor>` elements support `[aria-label]` and `[aria-labelledby]` attributes.
280+
151281
## Styling Formatted Content
152282
153283
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.

assets/index.html

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,10 @@
1717
max-width: 700px;
1818
}
1919

20+
trix-editor:invalid {
21+
border: solid 1px red;
22+
}
23+
2024
#output {
2125
margin: 1rem 0 0;
2226
}

karma.conf.js

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ const config = {
33
frameworks: [ "qunit" ],
44
files: [
55
{ pattern: "dist/test.js", watched: false },
6-
{ pattern: "src/test_helpers/fixtures/*.png", watched: false, included: false, served: true }
6+
{ pattern: "src/test/test_helpers/fixtures/*.png", watched: false, included: false, served: true }
77
],
88
proxies: {
99
"/test_helpers/fixtures/": "/base/src/test_helpers/fixtures/"
@@ -29,6 +29,14 @@ const config = {
2929

3030
/* eslint camelcase: "off", */
3131

32+
if (process.env.FORM_ASSOCIATED === "false") {
33+
config.files.push({
34+
pattern: "src/test/test_helpers/fixtures/form_associated_false.js",
35+
watched: false,
36+
included: true
37+
})
38+
}
39+
3240
if (process.env.SAUCE_ACCESS_KEY) {
3341
config.customLaunchers = {
3442
sl_chrome_latest: {

src/test/system/custom_element_test.js

Lines changed: 159 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import * as config from "trix/config"
12
import { rangesAreEqual } from "trix/core/helpers"
23

34
import {
@@ -500,7 +501,7 @@ testGroup("Custom element API", { template: "editor_empty" }, () => {
500501
testGroup("<label> support", { template: "editor_with_labels" }, () => {
501502
test("associates all label elements", () => {
502503
const labels = [ document.getElementById("label-1"), document.getElementById("label-3") ]
503-
assert.deepEqual(getEditorElement().labels, labels)
504+
assert.deepEqual(Array.from(getEditorElement().labels), labels)
504505
})
505506

506507
test("focuses when <label> clicked", () => {
@@ -538,4 +539,161 @@ testGroup("form property references its <form>", { template: "editors_with_forms
538539
const editor = document.getElementById("editor-with-no-form")
539540
assert.equal(editor.form, null)
540541
})
542+
543+
test("editor resets to its original value on element reset", async () => {
544+
const element = getEditorElement()
545+
546+
await typeCharacters("hello")
547+
element.reset()
548+
expectDocument("\n")
549+
})
550+
551+
test("element returns empty string when value is missing", () => {
552+
const element = getEditorElement()
553+
554+
assert.equal(element.value, "")
555+
})
556+
557+
test("editor returns its type", () => {
558+
const element = getEditorElement()
559+
560+
assert.equal("trix-editor", element.type)
561+
})
562+
563+
testIfFormAssociated("adds [disabled] attribute based on .disabled property", () => {
564+
const editor = document.getElementById("editor-with-ancestor-form")
565+
566+
editor.disabled = true
567+
568+
assert.equal(editor.hasAttribute("disabled"), true, "adds [disabled] attribute")
569+
570+
editor.disabled = false
571+
572+
assert.equal(editor.hasAttribute("disabled"), false, "removes [disabled] attribute")
573+
})
574+
575+
testIfFormAssociated("removes [contenteditable] and disables input when editor element has [disabled]", () => {
576+
const editor = document.getElementById("editor-with-no-form")
577+
578+
editor.setAttribute("disabled", "")
579+
580+
assert.equal(editor.matches(":disabled"), true, "sets :disabled CSS pseudostate")
581+
assert.equal(editor.inputElement.disabled, true, "disables input")
582+
assert.equal(editor.disabled, true, "exposes [disabled] attribute as .disabled property")
583+
assert.equal(editor.hasAttribute("contenteditable"), false, "removes [contenteditable] attribute")
584+
585+
editor.removeAttribute("disabled")
586+
587+
assert.equal(editor.matches(":disabled"), false, "removes sets :disabled pseudostate")
588+
assert.equal(editor.inputElement.disabled, false, "enabled input")
589+
assert.equal(editor.disabled, false, "updates .disabled property")
590+
assert.equal(editor.hasAttribute("contenteditable"), true, "adds [contenteditable] attribute")
591+
})
592+
593+
testIfFormAssociated("removes [contenteditable] and disables input when editor element is :disabled", () => {
594+
const editor = document.getElementById("editor-within-fieldset")
595+
const fieldset = document.getElementById("fieldset")
596+
597+
fieldset.disabled = true
598+
599+
assert.equal(editor.matches(":disabled"), true, "sets :disabled CSS pseudostate")
600+
assert.equal(editor.inputElement.disabled, true, "disables input")
601+
assert.equal(editor.disabled, true, "infers disabled state from ancestor")
602+
assert.equal(editor.hasAttribute("disabled"), false, "does not set [disabled] attribute")
603+
assert.equal(editor.hasAttribute("contenteditable"), false, "removes [contenteditable] attribute")
604+
605+
fieldset.disabled = false
606+
607+
assert.equal(editor.matches(":disabled"), false, "removes sets :disabled pseudostate")
608+
assert.equal(editor.inputElement.disabled, false, "enabled input")
609+
assert.equal(editor.disabled, false, "updates .disabled property")
610+
assert.equal(editor.hasAttribute("disabled"), false, "does not set [disabled] attribute")
611+
assert.equal(editor.hasAttribute("contenteditable"), true, "adds [contenteditable] attribute")
612+
})
613+
614+
testIfFormAssociated("does not receive focus when :disabled", () => {
615+
const activeEditor = document.getElementById("editor-with-input-form")
616+
const editor = document.getElementById("editor-within-fieldset")
617+
618+
activeEditor.focus()
619+
editor.disabled = true
620+
editor.focus()
621+
622+
assert.equal(activeEditor, document.activeElement, "disabled editor does not receive focus")
623+
})
624+
625+
testIfFormAssociated("disabled editor does not encode its value when the form is submitted", () => {
626+
const editor = document.getElementById("editor-with-ancestor-form")
627+
const form = editor.form
628+
629+
editor.inputElement.value = "Hello world"
630+
editor.disabled = true
631+
632+
assert.deepEqual({}, Object.fromEntries(new FormData(form).entries()), "does not write to FormData")
633+
})
634+
635+
testIfFormAssociated("validates with [required] attribute as invalid", () => {
636+
const editor = document.getElementById("editor-with-ancestor-form")
637+
const form = editor.form
638+
let invalidEvent, submitEvent = null
639+
640+
editor.addEventListener("invalid", event => invalidEvent = event, { once: true })
641+
form.addEventListener("submit", event => submitEvent = event, { once: true })
642+
643+
editor.required = true
644+
form.requestSubmit()
645+
646+
// assert.equal(document.activeElement, editor, "editor receives focus")
647+
assert.equal(editor.required, true, ".required property retrurns true")
648+
assert.equal(editor.validity.valid, false, "validity.valid is false")
649+
assert.equal(editor.validationMessage, "Please fill out this field.", "sets .validationMessage")
650+
assert.equal(invalidEvent.target, editor, "dispatches 'invalid' event on editor")
651+
assert.equal(submitEvent, null, "does not dispatch a 'submit' event")
652+
})
653+
654+
testIfFormAssociated("does not validate with [disabled] attribute", () => {
655+
const editor = document.getElementById("editor-with-ancestor-form")
656+
let invalidEvent = null
657+
658+
editor.disabled = true
659+
editor.required = true
660+
editor.addEventListener("invalid", event => invalidEvent = event, { once: true })
661+
editor.reportValidity()
662+
663+
assert.equal(invalidEvent, null, "does not dispatch an 'invalid' event")
664+
})
665+
666+
testIfFormAssociated("re-validates when the value changes", async () => {
667+
const editor = document.getElementById("editor-with-ancestor-form")
668+
editor.required = true
669+
editor.focus()
670+
671+
assert.equal(editor.validity.valid, false, "validity.valid is initially false")
672+
673+
await typeCharacters("a")
674+
675+
assert.equal(editor.validity.valid, true, "validity.valid is true after re-validating")
676+
assert.equal(editor.validity.valueMissing, false, "validity.valueMissing is false")
677+
assert.equal(editor.validationMessage, "", "clears the validationMessage")
678+
})
679+
680+
testIfFormAssociated("accepts a customError validation message", () => {
681+
const editor = document.getElementById("editor-with-ancestor-form")
682+
683+
editor.setCustomValidity("A custom validation message")
684+
685+
assert.equal(editor.validity.valid, false)
686+
assert.equal(editor.validity.customError, true)
687+
assert.equal(editor.validationMessage, "A custom validation message")
688+
})
541689
})
690+
691+
function testIfFormAssociated(name, callback) {
692+
test(name, async () => {
693+
if (config.editor.formAssociated) {
694+
await callback()
695+
} else {
696+
assert.equal(config.editor.formAssociated, false, "skipping test that requires ElementInternals")
697+
}
698+
})
699+
}
Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,5 @@
11
export default () =>
22
`<label id="label-1" for="editor"><span>Label 1</span></label>
3-
<label id="label-2">
4-
Label 2
5-
<trix-editor id="editor"></trix-editor>
6-
</label>
7-
<label id="label-3" for="editor">Label 3</label>`
3+
<label id="label-2">Label 2</label>
4+
<trix-editor id="editor"></trix-editor>
5+
<label id="label-3" for="editor">Label 3</label>`

0 commit comments

Comments
 (0)