Skip to content

Commit 2f8c59d

Browse files
Merge pull request basecamp#1188 from seanpdoyle/editor-element-internals
Integrate with `ElementInternals`
2 parents a15d0b8 + 1d65367 commit 2f8c59d

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
@@ -148,6 +148,63 @@ To populate a `<trix-editor>` with stored content, include that content in the a
148148

149149
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.
150150

151+
## Disabling the Editor
152+
153+
To disable the `<trix-editor>`, render it with the `[disabled]` attribute:
154+
155+
```html
156+
<trix-editor disabled></trix-editor>
157+
```
158+
159+
Disabled editors are not editable, cannot receive focus, and their values will
160+
be ignored when their related `<form>` element is submitted.
161+
162+
To change whether or not an editor is disabled, either toggle the `[disabled]`
163+
attribute or assign a boolean to the `.disabled` property:
164+
165+
```html
166+
<trix-editor id="editor" disabled></trix-editor>
167+
168+
<script>
169+
const editor = document.getElementById("editor")
170+
171+
editor.toggleAttribute("disabled", false)
172+
editor.disabled = true
173+
</script>
174+
```
175+
176+
When disabled, the editor will match the [:disabled CSS
177+
pseudo-class][:disabled].
178+
179+
[:disabled]: https://developer.mozilla.org/en-US/docs/Web/CSS/:disabled
180+
181+
## Providing an Accessible Name
182+
183+
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:
184+
185+
1. render the `<trix-editor>` element with an `[id]` attribute that the `<label>` element references through its `[for]` attribute:
186+
187+
```html
188+
<label for="editor">Editor</label>
189+
<trix-editor id="editor"></trix-editor>
190+
```
191+
192+
2. render the `<trix-editor>` element as a child of the `<label>` element:
193+
194+
```html
195+
<trix-toolbar id="editor-toolbar"></trix-toolbar>
196+
<label>
197+
Editor
198+
199+
<trix-editor toolbar="editor-toolbar"></trix-editor>
200+
</label>
201+
```
202+
203+
> [!WARNING]
204+
> 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.
205+
206+
In addition to integrating with `<label>` elements, `<trix-editor>` elements support `[aria-label]` and `[aria-labelledby]` attributes.
207+
151208
## Styling Formatted Content
152209

153210
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
@@ -495,12 +495,32 @@ testGroup("Custom element API", { template: "editor_empty" }, () => {
495495
form.removeEventListener("reset", preventDefault, false)
496496
expectDocument("hello\n")
497497
})
498+
499+
test("editor resets to its original value on element reset", async () => {
500+
const element = getEditorElement()
501+
502+
await typeCharacters("hello")
503+
element.reset()
504+
expectDocument("\n")
505+
})
506+
507+
test("element returns empty string when value is missing", () => {
508+
const element = getEditorElement()
509+
510+
assert.equal(element.value, "")
511+
})
512+
513+
test("editor returns its type", () => {
514+
const element = getEditorElement()
515+
516+
assert.equal("trix-editor", element.type)
517+
})
498518
})
499519

500520
testGroup("<label> support", { template: "editor_with_labels" }, () => {
501521
test("associates all label elements", () => {
502522
const labels = [ document.getElementById("label-1"), document.getElementById("label-3") ]
503-
assert.deepEqual(getEditorElement().labels, labels)
523+
assert.deepEqual(Array.from(getEditorElement().labels), labels)
504524
})
505525

506526
test("focuses when <label> clicked", () => {
@@ -521,7 +541,7 @@ testGroup("<label> support", { template: "editor_with_labels" }, () => {
521541
})
522542
})
523543

524-
testGroup("form property references its <form>", { template: "editors_with_forms", container: "div" }, () => {
544+
testGroup("integrates with its <form>", { template: "editors_with_forms", container: "div" }, () => {
525545
test("accesses its ancestor form", () => {
526546
const form = document.getElementById("ancestor-form")
527547
const editor = document.getElementById("editor-with-ancestor-form")
@@ -538,4 +558,76 @@ testGroup("form property references its <form>", { template: "editors_with_forms
538558
const editor = document.getElementById("editor-with-no-form")
539559
assert.equal(editor.form, null)
540560
})
561+
562+
test("adds [disabled] attribute based on .disabled property", () => {
563+
const editor = document.getElementById("editor-with-ancestor-form")
564+
565+
editor.disabled = true
566+
567+
assert.equal(editor.hasAttribute("disabled"), true, "adds [disabled] attribute")
568+
569+
editor.disabled = false
570+
571+
assert.equal(editor.hasAttribute("disabled"), false, "removes [disabled] attribute")
572+
})
573+
574+
test("removes [contenteditable] and disables input when editor element has [disabled]", () => {
575+
const editor = document.getElementById("editor-with-no-form")
576+
577+
editor.setAttribute("disabled", "")
578+
579+
assert.equal(editor.matches(":disabled"), true, "sets :disabled CSS pseudostate")
580+
assert.equal(editor.inputElement.disabled, true, "disables input")
581+
assert.equal(editor.disabled, true, "exposes [disabled] attribute as .disabled property")
582+
assert.equal(editor.hasAttribute("contenteditable"), false, "removes [contenteditable] attribute")
583+
584+
editor.removeAttribute("disabled")
585+
586+
assert.equal(editor.matches(":disabled"), false, "removes sets :disabled pseudostate")
587+
assert.equal(editor.inputElement.disabled, false, "enabled input")
588+
assert.equal(editor.disabled, false, "updates .disabled property")
589+
assert.equal(editor.hasAttribute("contenteditable"), true, "adds [contenteditable] attribute")
590+
})
591+
592+
test("removes [contenteditable] and disables input when editor element is :disabled", () => {
593+
const editor = document.getElementById("editor-within-fieldset")
594+
const fieldset = document.getElementById("fieldset")
595+
596+
fieldset.disabled = true
597+
598+
assert.equal(editor.matches(":disabled"), true, "sets :disabled CSS pseudostate")
599+
assert.equal(editor.inputElement.disabled, true, "disables input")
600+
assert.equal(editor.disabled, true, "infers disabled state from ancestor")
601+
assert.equal(editor.hasAttribute("disabled"), false, "does not set [disabled] attribute")
602+
assert.equal(editor.hasAttribute("contenteditable"), false, "removes [contenteditable] attribute")
603+
604+
fieldset.disabled = false
605+
606+
assert.equal(editor.matches(":disabled"), false, "removes sets :disabled pseudostate")
607+
assert.equal(editor.inputElement.disabled, false, "enabled input")
608+
assert.equal(editor.disabled, false, "updates .disabled property")
609+
assert.equal(editor.hasAttribute("disabled"), false, "does not set [disabled] attribute")
610+
assert.equal(editor.hasAttribute("contenteditable"), true, "adds [contenteditable] attribute")
611+
})
612+
613+
test("does not receive focus when :disabled", () => {
614+
const activeEditor = document.getElementById("editor-with-input-form")
615+
const editor = document.getElementById("editor-within-fieldset")
616+
617+
activeEditor.focus()
618+
editor.disabled = true
619+
editor.focus()
620+
621+
assert.equal(activeEditor, document.activeElement, "disabled editor does not receive focus")
622+
})
623+
624+
test("disabled editor does not encode its value when the form is submitted", () => {
625+
const editor = document.getElementById("editor-with-ancestor-form")
626+
const form = editor.form
627+
628+
editor.inputElement.value = "Hello world"
629+
editor.disabled = true
630+
631+
assert.deepEqual({}, Object.fromEntries(new FormData(form).entries()), "does not write to FormData")
632+
})
541633
})
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 this.localName
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)