Skip to content

Commit 7046f28

Browse files
committed
Integrate with ElementInternals
Closes [basecamp#1023][] Integrate with `<form>` elements directly through built-in support for [ElementInternals][]. 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. For now, keep the changes minimal. Future changes will handle integrating with more parts of `ElementInternals`. TODO after merging: --- - [ ] Integrate with [ElementInternals.willValidate](https://developer.mozilla.org/en-US/docs/Web/API/ElementInternals/willValidate), [ElementInternals.validity](https://developer.mozilla.org/en-US/docs/Web/API/ElementInternals/validity), [ElementInternals.validationMessage](https://developer.mozilla.org/en-US/docs/Web/API/ElementInternals/validationMessage) - [ ] [Form callbacks](https://web.dev/articles/more-capable-form-controls#lifecycle_callbacks) like `void formDisabledCallback(disabled)` to support `[disabled]` - [ ] [Instance properties included from ARIA](https://developer.mozilla.org/en-US/docs/Web/API/ElementInternals#instance_properties_included_from_aria) [basecamp#1023]: basecamp#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 457a834 commit 7046f28

File tree

5 files changed

+183
-59
lines changed

5 files changed

+183
-59
lines changed

README.md

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,63 @@ To populate a `<trix-editor>` with stored content, include that content in the a
7979

8080
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.
8181

82+
## Disabling the Editor
83+
84+
To disable the `<trix-editor>`, render it with the `[disabled]` attribute:
85+
86+
```html
87+
<trix-editor disabled></trix-editor>
88+
```
89+
90+
Disabled editors are not editable, cannot receive focus, and their values will
91+
be ignored when their related `<form>` element is submitted.
92+
93+
To change whether or not an editor is disabled, either toggle the `[disabled]`
94+
attribute or assign a boolean to the `.disabled` property:
95+
96+
```html
97+
<trix-editor id="editor" disabled></trix-editor>
98+
99+
<script>
100+
const editor = document.getElementById("editor")
101+
102+
editor.toggleAttribute("disabled", false)
103+
editor.disabled = true
104+
</script>
105+
```
106+
107+
When disabled, the editor will match the [:disabled CSS
108+
pseudo-class][:disabled].
109+
110+
[:disabled]: https://developer.mozilla.org/en-US/docs/Web/CSS/:disabled
111+
112+
## Providing an Accessible Name
113+
114+
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:
115+
116+
1. render the `<trix-editor>` element with an `[id]` attribute that the `<label>` element references through its `[for]` attribute:
117+
118+
```html
119+
<label for="editor">Editor</label>
120+
<trix-editor id="editor"></trix-editor>
121+
```
122+
123+
2. render the `<trix-editor>` element as a child of the `<label>` element:
124+
125+
```html
126+
<trix-toolbar id="editor-toolbar"></trix-toolbar>
127+
<label>
128+
Editor
129+
130+
<trix-editor toolbar="editor-toolbar"></trix-editor>
131+
</label>
132+
```
133+
134+
> [!WARNING]
135+
> 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.
136+
137+
In addition to integrating with `<label>` elements, `<trix-editor>` elements support `[aria-label]` and `[aria-labelledby]` attributes.
138+
82139
## Styling Formatted Content
83140

84141
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.

src/test/system/custom_element_test.js

Lines changed: 94 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -471,12 +471,32 @@ testGroup("Custom element API", { template: "editor_empty" }, () => {
471471
form.removeEventListener("reset", preventDefault, false)
472472
expectDocument("hello\n")
473473
})
474+
475+
test("editor resets to its original value on element reset", async () => {
476+
const element = getEditorElement()
477+
478+
await typeCharacters("hello")
479+
element.reset()
480+
expectDocument("\n")
481+
})
482+
483+
test("element returns empty string when value is missing", () => {
484+
const element = getEditorElement()
485+
486+
assert.equal(element.value, "")
487+
})
488+
489+
test("editor returns its type", () => {
490+
const element = getEditorElement()
491+
492+
assert.equal("trix-editor", element.type)
493+
})
474494
})
475495

476496
testGroup("<label> support", { template: "editor_with_labels" }, () => {
477497
test("associates all label elements", () => {
478498
const labels = [ document.getElementById("label-1"), document.getElementById("label-3") ]
479-
assert.deepEqual(getEditorElement().labels, labels)
499+
assert.deepEqual(Array.from(getEditorElement().labels), labels)
480500
})
481501

482502
test("focuses when <label> clicked", () => {
@@ -497,7 +517,7 @@ testGroup("<label> support", { template: "editor_with_labels" }, () => {
497517
})
498518
})
499519

500-
testGroup("form property references its <form>", { template: "editors_with_forms", container: "div" }, () => {
520+
testGroup("integrates with its <form>", { template: "editors_with_forms", container: "div" }, () => {
501521
test("accesses its ancestor form", () => {
502522
const form = document.getElementById("ancestor-form")
503523
const editor = document.getElementById("editor-with-ancestor-form")
@@ -514,4 +534,76 @@ testGroup("form property references its <form>", { template: "editors_with_forms
514534
const editor = document.getElementById("editor-with-no-form")
515535
assert.equal(editor.form, null)
516536
})
537+
538+
test("adds [disabled] attribute based on .disabled property", () => {
539+
const editor = document.getElementById("editor-with-ancestor-form")
540+
541+
editor.disabled = true
542+
543+
assert.equal(editor.hasAttribute("disabled"), true, "adds [disabled] attribute")
544+
545+
editor.disabled = false
546+
547+
assert.equal(editor.hasAttribute("disabled"), false, "removes [disabled] attribute")
548+
})
549+
550+
test("removes [contenteditable] and disables input when editor element has [disabled]", () => {
551+
const editor = document.getElementById("editor-with-no-form")
552+
553+
editor.setAttribute("disabled", "")
554+
555+
assert.equal(editor.matches(":disabled"), true, "sets :disabled CSS pseudostate")
556+
assert.equal(editor.inputElement.disabled, true, "disables input")
557+
assert.equal(editor.disabled, true, "exposes [disabled] attribute as .disabled property")
558+
assert.equal(editor.hasAttribute("contenteditable"), false, "removes [contenteditable] attribute")
559+
560+
editor.removeAttribute("disabled")
561+
562+
assert.equal(editor.matches(":disabled"), false, "removes sets :disabled pseudostate")
563+
assert.equal(editor.inputElement.disabled, false, "enabled input")
564+
assert.equal(editor.disabled, false, "updates .disabled property")
565+
assert.equal(editor.hasAttribute("contenteditable"), true, "adds [contenteditable] attribute")
566+
})
567+
568+
test("removes [contenteditable] and disables input when editor element is :disabled", () => {
569+
const editor = document.getElementById("editor-within-fieldset")
570+
const fieldset = document.getElementById("fieldset")
571+
572+
fieldset.disabled = true
573+
574+
assert.equal(editor.matches(":disabled"), true, "sets :disabled CSS pseudostate")
575+
assert.equal(editor.inputElement.disabled, true, "disables input")
576+
assert.equal(editor.disabled, true, "infers disabled state from ancestor")
577+
assert.equal(editor.hasAttribute("disabled"), false, "does not set [disabled] attribute")
578+
assert.equal(editor.hasAttribute("contenteditable"), false, "removes [contenteditable] attribute")
579+
580+
fieldset.disabled = false
581+
582+
assert.equal(editor.matches(":disabled"), false, "removes sets :disabled pseudostate")
583+
assert.equal(editor.inputElement.disabled, false, "enabled input")
584+
assert.equal(editor.disabled, false, "updates .disabled property")
585+
assert.equal(editor.hasAttribute("disabled"), false, "does not set [disabled] attribute")
586+
assert.equal(editor.hasAttribute("contenteditable"), true, "adds [contenteditable] attribute")
587+
})
588+
589+
test("does not receive focus when :disabled", () => {
590+
const activeEditor = document.getElementById("editor-with-input-form")
591+
const editor = document.getElementById("editor-within-fieldset")
592+
593+
activeEditor.focus()
594+
editor.disabled = true
595+
editor.focus()
596+
597+
assert.equal(activeEditor, document.activeElement, "disabled editor does not receive focus")
598+
})
599+
600+
test("disabled editor does not encode its value when the form is submitted", () => {
601+
const editor = document.getElementById("editor-with-ancestor-form")
602+
const form = editor.form
603+
604+
editor.inputElement.value = "Hello world"
605+
editor.disabled = true
606+
607+
assert.deepEqual({}, Object.fromEntries(new FormData(form).entries()), "does not write to FormData")
608+
})
517609
})
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>`
Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
export default () =>
22
`<form id="ancestor-form">
3-
<trix-editor id="editor-with-ancestor-form"></trix-editor>
3+
<trix-editor id="editor-with-ancestor-form" name="editor-with-ancestor-form"></trix-editor>
44
</form>
55
66
<form id="input-form">
77
<input type="hidden" id="hidden-input">
88
</form>
99
<trix-editor id="editor-with-input-form" input="hidden-input"></trix-editor>
1010
11-
<trix-editor id="editor-with-no-form"></trix-editor>`
11+
<trix-editor id="editor-with-no-form"></trix-editor>
12+
<fieldset id="fieldset"><trix-editor id="editor-within-fieldset"></fieldset>`

src/trix/elements/trix_editor_element.js

Lines changed: 26 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import * as config from "trix/config"
22

33
import {
4-
findClosestElementFromNode,
54
handleEvent,
65
handleEventOnce,
76
installDefaultCSSForTagName,
@@ -161,6 +160,14 @@ installDefaultCSSForTagName("trix-editor", `\
161160
}`)
162161

163162
export default class TrixEditorElement extends HTMLElement {
163+
static formAssociated = true
164+
165+
#internals
166+
167+
constructor() {
168+
super()
169+
this.#internals = this.attachInternals()
170+
}
164171

165172
// Properties
166173

@@ -174,19 +181,7 @@ export default class TrixEditorElement extends HTMLElement {
174181
}
175182

176183
get labels() {
177-
const labels = []
178-
if (this.id && this.ownerDocument) {
179-
labels.push(...Array.from(this.ownerDocument.querySelectorAll(`label[for='${this.id}']`) || []))
180-
}
181-
182-
const label = findClosestElementFromNode(this, { matchingSelector: "label" })
183-
if (label) {
184-
if ([ this, null ].includes(label.control)) {
185-
labels.push(label)
186-
}
187-
}
188-
189-
return labels
184+
return this.#internals.labels
190185
}
191186

192187
get toolbarElement() {
@@ -238,6 +233,18 @@ export default class TrixEditorElement extends HTMLElement {
238233
this.editor?.loadHTML(this.defaultValue)
239234
}
240235

236+
get disabled() {
237+
return this.inputElement.disabled
238+
}
239+
240+
set disabled(value) {
241+
this.toggleAttribute("disabled")
242+
}
243+
244+
get type() {
245+
return "trix-editor"
246+
}
247+
241248
// Controller delegate methods
242249

243250
notify(message, data) {
@@ -269,54 +276,23 @@ export default class TrixEditorElement extends HTMLElement {
269276
requestAnimationFrame(() => triggerEvent("trix-initialize", { onElement: this }))
270277
}
271278
this.editorController.registerSelectionManager()
272-
this.registerResetListener()
273-
this.registerClickListener()
274279
autofocus(this)
275280
}
276281
}
277282

278283
disconnectedCallback() {
279284
this.editorController?.unregisterSelectionManager()
280-
this.unregisterResetListener()
281-
return this.unregisterClickListener()
282285
}
283286

284287
// Form support
285288

286-
registerResetListener() {
287-
this.resetListener = this.resetBubbled.bind(this)
288-
return window.addEventListener("reset", this.resetListener, false)
289-
}
290-
291-
unregisterResetListener() {
292-
return window.removeEventListener("reset", this.resetListener, false)
293-
}
294-
295-
registerClickListener() {
296-
this.clickListener = this.clickBubbled.bind(this)
297-
return window.addEventListener("click", this.clickListener, false)
298-
}
299-
300-
unregisterClickListener() {
301-
return window.removeEventListener("click", this.clickListener, false)
302-
}
303-
304-
resetBubbled(event) {
305-
if (event.defaultPrevented) return
306-
if (event.target !== this.form) return
307-
return this.reset()
289+
formDisabledCallback(disabled) {
290+
this.inputElement.disabled = disabled
291+
this.toggleAttribute("contenteditable", !disabled)
308292
}
309293

310-
clickBubbled(event) {
311-
if (event.defaultPrevented) return
312-
if (this.contains(event.target)) return
313-
314-
const label = findClosestElementFromNode(event.target, { matchingSelector: "label" })
315-
if (!label) return
316-
317-
if (!Array.from(this.labels).includes(label)) return
318-
319-
return this.focus()
294+
formResetCallback() {
295+
this.reset()
320296
}
321297

322298
reset() {

0 commit comments

Comments
 (0)