Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Integrate with ElementInternals #1188

Merged
merged 2 commits into from
Oct 1, 2024
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 57 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,63 @@ 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.

## 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
96 changes: 94 additions & 2 deletions src/test/system/custom_element_test.js
Original file line number Diff line number Diff line change
Expand Up @@ -471,12 +471,32 @@ testGroup("Custom element API", { template: "editor_empty" }, () => {
form.removeEventListener("reset", preventDefault, false)
expectDocument("hello\n")
})

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)
})
})

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 All @@ -497,7 +517,7 @@ testGroup("<label> support", { template: "editor_with_labels" }, () => {
})
})

testGroup("form property references its <form>", { template: "editors_with_forms", container: "div" }, () => {
testGroup("integrates with its <form>", { template: "editors_with_forms", container: "div" }, () => {
test("accesses its ancestor form", () => {
const form = document.getElementById("ancestor-form")
const editor = document.getElementById("editor-with-ancestor-form")
Expand All @@ -514,4 +534,76 @@ testGroup("form property references its <form>", { template: "editors_with_forms
const editor = document.getElementById("editor-with-no-form")
assert.equal(editor.form, null)
})

test("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")
})

test("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")
})

test("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")
})

test("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")
})

test("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")
})
})
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>`
76 changes: 26 additions & 50 deletions src/trix/elements/trix_editor_element.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import * as config from "trix/config"

import {
findClosestElementFromNode,
handleEvent,
handleEventOnce,
installDefaultCSSForTagName,
Expand Down Expand Up @@ -161,6 +160,14 @@ installDefaultCSSForTagName("trix-editor", `\
}`)

export default class TrixEditorElement extends HTMLElement {
static formAssociated = true

#internals

constructor() {
super()
this.#internals = this.attachInternals()
}

// Properties

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

get labels() {
const labels = []
if (this.id && this.ownerDocument) {
labels.push(...Array.from(this.ownerDocument.querySelectorAll(`label[for='${this.id}']`) || []))
}

const label = findClosestElementFromNode(this, { matchingSelector: "label" })
if (label) {
if ([ this, null ].includes(label.control)) {
labels.push(label)
}
}

return labels
return this.#internals.labels
}

get toolbarElement() {
Expand Down Expand Up @@ -238,6 +233,18 @@ export default class TrixEditorElement extends HTMLElement {
this.editor?.loadHTML(this.defaultValue)
}

get disabled() {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The "disabled" bit is separated from the "element internals" change, right? I like both, but are they related?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The integration with ElementInternals provides access to the formDisabledCallback(disabled) lifecycle hook. Through that integration the element is given an opportunity to disable or re-enable its associated <input>. The [disabled] attribute is a common attribute among form controls, and provides the server an opportunity to render the element with an initially disabled state. However, the [disabled] attribute is only one aspect of disabling an element. If it's rendered within a <fieldset disabled>, the formDisabledCallback(disabled) will fire (and the associated <input> will be disabled anyway if its a child of the <fieldset> whether ElementInternals is integrated or not). There is also a :disabled CSS pseudo-selector that incorporates both [disabled] on the element itself and :disabled of any ancestor <fieldset> element.

The .disabled property itself is an enhancement that's separate from the bare necessities.

I've included it here because applications that want to introspect whether or not the element is disabled are unlikely to get it right when incorporating [disabled] on the element, :disabled on its <fieldset> ancestors (or their HTMLFieldSetElement.disabled property).

Since the <input> element is built-in, it already accounts for all these permutations. The current formDisabledCallback(disabled) implementation already manages the HTMLInputElement.disabled property, so the <input> felt like a good delegate for the time being. If the <trix-editor> is ever changed to managed its own value in a way that makes the <input> no longer necessary, this property could be changed to handle the rest of the logic.

return this.inputElement.disabled
}

set disabled(value) {
this.toggleAttribute("disabled")
}

get type() {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this just for testing purposes? Could you replace with just instanceof in tests?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It isn't solely for testing. I've added the property because it's mentioned in the Defining a form-associated custom element section of the More capable form controls article.

Having said that, it's implemented using Element.localName (and not hard-coded like I've done here). It also doesn't seem to be mentioned by the MDN documentation for ElementInternals, so it isn't clear how necessary it is to start.

return "trix-editor"
seanpdoyle marked this conversation as resolved.
Show resolved Hide resolved
}

// Controller delegate methods

notify(message, data) {
Expand Down Expand Up @@ -269,54 +276,23 @@ export default class TrixEditorElement extends HTMLElement {
requestAnimationFrame(() => triggerEvent("trix-initialize", { onElement: this }))
}
this.editorController.registerSelectionManager()
this.registerResetListener()
this.registerClickListener()
autofocus(this)
}
}

disconnectedCallback() {
this.editorController?.unregisterSelectionManager()
this.unregisterResetListener()
return this.unregisterClickListener()
}

// Form support

registerResetListener() {
this.resetListener = this.resetBubbled.bind(this)
return window.addEventListener("reset", this.resetListener, false)
}

unregisterResetListener() {
return window.removeEventListener("reset", this.resetListener, false)
}

registerClickListener() {
this.clickListener = this.clickBubbled.bind(this)
return window.addEventListener("click", this.clickListener, false)
}

unregisterClickListener() {
return window.removeEventListener("click", this.clickListener, false)
}

resetBubbled(event) {
if (event.defaultPrevented) return
if (event.target !== this.form) return
return this.reset()
formDisabledCallback(disabled) {
this.inputElement.disabled = disabled
this.toggleAttribute("contenteditable", !disabled)
}

clickBubbled(event) {
if (event.defaultPrevented) return
if (this.contains(event.target)) return

const label = findClosestElementFromNode(event.target, { matchingSelector: "label" })
if (!label) return

if (!Array.from(this.labels).includes(label)) return

return this.focus()
formResetCallback() {
this.reset()
}

reset() {
Expand Down
Loading