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 #1128

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
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 (formAssociated: ${{ matrix.formAssociated }})"
runs-on: ubuntu-latest
strategy:
matrix:
elementInternals: [false, true]
env:
EDITOR_FORM_ASSOCIATED: "${{ matrix.formAssociated }}"
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-editor-formAssociated" 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") === "formAssociated") {
document.head.insertAdjacentHTML(
"beforeend",
`<meta name="trix-editor-formAssociated" 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=formAssociated">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" input="input" 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_FORM_ASSOCIATED === "true") {
config.files.unshift({ pattern: "src/test/test_helpers/fixtures/form_associated.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.formAssociated, "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.formAssociated, "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.formAssociated, "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)
})
})
47 changes: 40 additions & 7 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 @@ -442,7 +444,7 @@ testGroup("Custom element API", { template: "editor_empty" }, () => {

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

await typeCharacters("hello")
form.reset()
Expand All @@ -451,7 +453,7 @@ testGroup("Custom element API", { template: "editor_empty" }, () => {

test("editor resets to last-set value on form reset", async () => {
const element = getEditorElement()
const { form } = element.inputElement
const { form } = element

element.value = "hi"
await typeCharacters("hello")
Expand All @@ -461,7 +463,7 @@ testGroup("Custom element API", { template: "editor_empty" }, () => {

test("editor respects preventDefault on form reset", async () => {
const element = getEditorElement()
const { form } = element.inputElement
const { form } = element
const preventDefault = (event) => event.preventDefault()

await typeCharacters("hello")
Expand All @@ -471,20 +473,51 @@ testGroup("Custom element API", { template: "editor_empty" }, () => {
form.removeEventListener("reset", preventDefault, false)
expectDocument("hello\n")
})

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

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

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

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

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

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

testGroup("HTML sanitization", { template: "editor_html" }, () => {
test("ignores text nodes in script elements", () => {
const element = getEditorElement()
element.value = "<div>safe</div><script>alert(\"unsafe\")</script>"

expectDocument("safe\n")
assert.equal(element.innerHTML, "<div><!--block-->safe</div>")
assert.equal(element.value, "<div>safe</div>")
})
})

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.formAssociated, "focuses when <label> clicked", () => {
document.getElementById("label-1").click()
assert.equal(getEditorElement(), document.activeElement)
})

test("focuses when <label> descendant clicked", () => {
testUnless(config.editor.formAssociated, "focuses when <label> descendant clicked", () => {
document.getElementById("label-1").querySelector("span").click()
assert.equal(getEditorElement(), document.activeElement)
})
Expand All @@ -504,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 Down
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 elements", () => {
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.formAssociated, "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.formAssociated ?
`<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.formAssociated ?
`<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;">`