Skip to content

Commit

Permalink
Integrate with ElementInternals
Browse files Browse the repository at this point in the history
Closes [basecamp#1023][]

Replace the requirement for an `<input type="hidden">` element with
direct `<form>` integration 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 `<input type="hidden">`-`[input]` attribute pairing can
be achieved through [ElementInternals.setFormValue][]. Similarly, the
`<label>` element support can be achieved through
[ElementInternals.labels][].

[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
  • Loading branch information
seanpdoyle committed Feb 9, 2024
1 parent b86322f commit 92dd79e
Show file tree
Hide file tree
Showing 17 changed files with 302 additions and 77 deletions.
7 changes: 6 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,13 @@ on:

jobs:
build:
name: Browser tests
name: "Browser tests (ElementInternals: ${{ matrix.elementInternals }})"
runs-on: ubuntu-latest
strategy:
matrix:
elementInternals: [false, true]
env:
EDITOR_ELEMENT_INTERNALS: "${{ matrix.elementInternals }}"
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
Expand Down
85 changes: 82 additions & 3 deletions 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 @@ -49,12 +49,26 @@ document.addEventListener("trix-before-initialize", () => {

## Creating an Editor

Place an empty `<trix-editor></trix-editor>` tag on the page. Trix will automatically insert a separate `<trix-toolbar>` before the editor.
Place an empty `<trix-editor></trix-editor>` tag on the page. If the `<trix-editor>` element is rendered with a `[toolbar]` attribute that references the element by its `[id]`, it will treat that element as its toolbar:

```html
<trix-toolbar id="editor_toolbar"></trix-toolbar>

<trix-editor toolbar="editor_toolbar"></trix-editor>
```

Otherwise, Trix will automatically insert a separate `<trix-toolbar>` before the editor.

Like an HTML `<textarea>`, `<trix-editor>` accepts `autofocus` and `placeholder` attributes. Unlike a `<textarea>`, `<trix-editor>` automatically expands vertically to fit its contents.

## Integrating With Forms

There are two styles of integrating with `<form>` element submissions.

### Legacy integration with `<input type="hidden">`

Legacy support is provided through an `<input type="hidden">` element paired with an `[input]` attribute on the `<trix-editor>` element.

To submit the contents of a `<trix-editor>` with a form, first define a hidden input field in the form and assign it an `id`. Then reference that `id` in the editor’s `input` attribute.

```html
Expand All @@ -66,7 +80,7 @@ To submit the contents of a `<trix-editor>` with a form, first define a hidden i

Trix will automatically update the value of the hidden input field with each change to the editor.

## Populating With Stored Content
#### Populating With Stored Content

To populate a `<trix-editor>` with stored content, include that content in the associated input element’s `value` attribute.

Expand All @@ -79,6 +93,71 @@ 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.

### Integration with Element Internals

Trix can also be configured to integrate with forms through the `<trix-editor>` element's `ElementInternals` instance.

First, configure Trix to opt-into its Element Internals support by rendering a `<meta>` element into the document's `<head>`:

```html
<head>
<!---->
<meta name="trix-config-editor-element-internals" content="true">
</head>
```

Then, to submit the contents of a `<trix-editor>` with a `<form>`, render the element with a `[name]` attribute and its initial value as its inner HTML.

```html
<form >
<trix-editor name="content"></trix-editor>
</form>
```

To associate the element with a `<form>` that isn't an ancestor, render the element with a `[form]` attribute that references the `<form>` element by its `[id]`:

```html
<form id="a-form-element" ></form>
<trix-editor name="content" form="a-form-element"></trix-editor>
```

#### Populating With Stored Content

To populate a `<trix-editor>` with stored content, include that content as HTML inside the element’s inner HTML.

```html
<form >
<trix-editor>Editor content goes here</trix-editor>
</form>
```

## 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
24 changes: 22 additions & 2 deletions assets/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
box-sizing: border-box;
}

main {
main, nav {
margin: 20px auto;
max-width: 700px;
}
Expand Down Expand Up @@ -69,10 +69,30 @@
}
});
</script>
<script>
const searchParams = new URLSearchParams(location.search)

if (searchParams.get("editor") === "elementInternals") {
document.head.insertAdjacentHTML(
"beforeend",
`<meta name="trix-config-editor-element-internals" content="true">`
)

document.addEventListener("trix-change", function(event) {
var input = document.getElementById("input")
input.value = event.target.value
})
}
</script>
</head>
<body>
<nav>
<a href="/">Legacy support</a>
<a href="/?editor=elementInternals">ElementInternals support</a>
</nav>
<main>
<trix-editor autofocus class="trix-content" input="input"></trix-editor>
<label for="editor">Input</label>
<trix-editor autofocus class="trix-content" id="editor"></trix-editor>
<details id="output">
<summary>Output</summary>
<textarea readonly id="input"></textarea>
Expand Down
6 changes: 5 additions & 1 deletion karma.conf.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ const config = {
frameworks: [ "qunit" ],
files: [
{ pattern: "dist/test.js", watched: false },
{ pattern: "src/test_helpers/fixtures/*.png", watched: false, included: false, served: true }
{ pattern: "src/test/test_helpers/fixtures/*.png", watched: false, included: false, served: true }
],
proxies: {
"/test_helpers/fixtures/": "/base/src/test_helpers/fixtures/"
Expand Down Expand Up @@ -101,6 +101,10 @@ if (process.env.SAUCE_ACCESS_KEY) {
}
}

if (process.env.EDITOR_ELEMENT_INTERNALS === "true") {
config.files.unshift({ pattern: "src/test/test_helpers/fixtures/element_internals.js", watched: false, included: true })
}

function buildId() {
const { GITHUB_WORKFLOW, GITHUB_RUN_NUMBER, GITHUB_RUN_ID } = process.env
return GITHUB_WORKFLOW && GITHUB_RUN_NUMBER && GITHUB_RUN_ID
Expand Down
16 changes: 13 additions & 3 deletions src/test/system/accessibility_test.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { assert, test, testGroup, triggerEvent } from "test/test_helper"
import * as config from "trix/config"
import { assert, test, testGroup, testIf, testUnless, triggerEvent } from "test/test_helper"

testGroup("Accessibility attributes", { template: "editor_default_aria_label" }, () => {
test("sets the role to textbox", () => {
Expand All @@ -22,17 +23,26 @@ testGroup("Accessibility attributes", { template: "editor_default_aria_label" },
assert.equal(editor.getAttribute("aria-labelledby"), "aria-labelledby-id")
})

test("assigns aria-label to the text of the element's <label> elements", () => {
testUnless(config.editor.elementInternals, "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", () => {
testUnless(config.editor.elementInternals, "updates the aria-label on focus", () => {
const editor = document.getElementById("editor-with-modified-label")
const label = document.getElementById("modified-label")

label.innerHTML = "<span>New Value</span>"
triggerEvent(editor, "focus")
assert.equal(editor.getAttribute("aria-label"), "New Value")
})

testIf(config.editor.elementInternals, "does not set [aria-label] for a <label> element", () => {
const editor = document.getElementById("editor-with-labels")
const labels = Array.from(editor.labels)
const text = labels.map((label) => label.textContent.trim())

assert.deepEqual(text, [ "Label 1", "Label 2", "Label 3" ])
assert.equal(editor.getAttribute("aria-label"), null)
})
})
50 changes: 26 additions & 24 deletions src/test/system/custom_element_test.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import * as config from "trix/config"
import { rangesAreEqual } from "trix/core/helpers"

import {
Expand All @@ -13,6 +14,7 @@ import {
test,
testGroup,
testIf,
testUnless,
triggerEvent,
typeCharacters,
typeInToolbarDialog,
Expand Down Expand Up @@ -391,12 +393,6 @@ testGroup("Custom element API", { template: "editor_empty" }, () => {
})
})

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

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

test("element serializes HTML after attribute changes", async () => {
const element = getEditorElement()
let serializedHTML = element.value
Expand Down Expand Up @@ -446,14 +442,6 @@ testGroup("Custom element API", { template: "editor_empty" }, () => {
return promise
})

test("editor resets to its original value on element reset", async () => {
const element = getEditorElement()

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

test("editor resets to its original value on form reset", async () => {
const element = getEditorElement()
const { form } = element
Expand Down Expand Up @@ -485,6 +473,26 @@ 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", async () => {
const element = getEditorElement()

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

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

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

testGroup("HTML sanitization", { template: "editor_html" }, () => {
Expand All @@ -501,15 +509,15 @@ testGroup("HTML sanitization", { template: "editor_html" }, () => {
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", () => {
testUnless(config.editor.elementInternals, "focuses when <label> clicked", () => {
document.getElementById("label-1").click()
assert.equal(getEditorElement(), document.activeElement)
})

test("focuses when <label> descendant clicked", () => {
testUnless(config.editor.elementInternals, "focuses when <label> descendant clicked", () => {
document.getElementById("label-1").querySelector("span").click()
assert.equal(getEditorElement(), document.activeElement)
})
Expand All @@ -529,7 +537,7 @@ testGroup("form property references its <form>", { template: "editors_with_forms
assert.equal(editor.form, form)
})

test("transitively accesses its related <input> element's <form>", () => {
test("transitively accesses its related <form>", () => {
const form = document.getElementById("input-form")
const editor = document.getElementById("editor-with-input-form")
assert.equal(editor.form, form)
Expand All @@ -539,10 +547,4 @@ 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 returns its type", async() => {
const element = getEditorElement()

assert.equal("trix-editor", element.type)
})
})
13 changes: 9 additions & 4 deletions src/test/system/installation_process_test.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import * as config from "trix/config"
import EditorController from "trix/controllers/editor_controller"

import { assert, test, testGroup } from "test/test_helper"
import { assert, test, testGroup, testUnless } from "test/test_helper"
import { nextFrame } from "../test_helpers/timing_helpers"

testGroup("Installation process", { template: "editor_html" }, () => {
Expand All @@ -20,23 +21,27 @@ testGroup("Installation process", { template: "editor_html" }, () => {
})
})

testGroup("Installation process without specified elements", { template: "editor_empty" }, () =>
test("creates identified toolbar and input elements", () => {
testGroup("Installation process without specified elements", { template: "editor_empty" }, () => {
test("creates identified toolbar", () => {
const editorElement = getEditorElement()

const toolbarId = editorElement.getAttribute("toolbar")
assert.ok(/trix-toolbar-\d+/.test(toolbarId), `toolbar id not assert.ok ${JSON.stringify(toolbarId)}`)
const toolbarElement = document.getElementById(toolbarId)
assert.ok(toolbarElement, "toolbar element not assert.ok")
assert.equal(editorElement.toolbarElement, toolbarElement)
})

testUnless(config.editor.elementInternals, "creates identified input elements", () => {
const editorElement = getEditorElement()

const inputId = editorElement.getAttribute("input")
assert.ok(/trix-input-\d+/.test(inputId), `input id not assert.ok ${JSON.stringify(inputId)}`)
const inputElement = document.getElementById(inputId)
assert.ok(inputElement, "input element not assert.ok")
assert.equal(editorElement.inputElement, inputElement)
})
)
})

testGroup("Installation process with specified elements", { template: "editor_with_toolbar_and_input" }, () => {
test("uses specified elements", () => {
Expand Down
9 changes: 7 additions & 2 deletions src/test/test_helpers/fixtures/editor_html.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
import * as config from "trix/config"

export default () =>
`<input id="my_input" type="hidden" value="&lt;div&gt;Hello world&lt;/div&gt;">
<trix-editor input="my_input" autofocus placeholder="Say hello..."></trix-editor>`
config.editor.elementInternals ?
`<trix-editor autofocus placeholder="Say hello..."><div>Hello world</div></trix-editor>
` :
`<input id="my_input" type="hidden" value="&lt;div&gt;Hello world&lt;/div&gt;">
<trix-editor input="my_input" autofocus placeholder="Say hello..."></trix-editor>`
8 changes: 6 additions & 2 deletions src/test/test_helpers/fixtures/editor_with_image.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import * as config from "trix/config"
import { TEST_IMAGE_URL } from "./test_image_url"

export default () =>
`<trix-editor input="my_input" autofocus placeholder="Say hello..."></trix-editor>
<input id="my_input" type="hidden" value="ab&lt;img src=&quot;${TEST_IMAGE_URL}&quot; width=&quot;10&quot; height=&quot;10&quot;&gt;">`
config.editor.elementInternals ?
`<trix-editor autofocus placeholder="Say hello...">ab<img src="${TEST_IMAGE_URL}" width="10" height="10"></trix-editor>
` :
`<trix-editor input="my_input" autofocus placeholder="Say hello..."></trix-editor>
<input id="my_input" type="hidden" value="ab&lt;img src=&quot;${TEST_IMAGE_URL}&quot; width=&quot;10&quot; height=&quot;10&quot;&gt;">`
Loading

0 comments on commit 92dd79e

Please sign in to comment.