@thoughtbot/trix-mentions-element
Version:
Activates a suggestion menu to embed attachments as you type.
379 lines (295 loc) • 12.5 kB
Markdown
# <trix-mentions> element
Activates a suggestion menu to expand text snippets as you type.
Inspired by [/text-expander-element][].
[/text-expander-element]: https://github.com/github/text-expander-element
## Installation
```
$ npm install --save /trix-mentions-element
```
## Usage
### Script
Import as ES modules:
```js
import Trix from 'trix'
import '@thoughtbot/trix-mentions-element'
window.Trix = Trix
```
With a script tag:
```html
<script type="module" src="./node_modules/@thoughtbot/trix-mentions-element/dist/bundle.js">
```
### Markup
```html
<trix-mentions keys="@ #" multiword="#">
<trix-editor></trix-editor>
</trix-mentions>
```
## Attributes
- `keys` is a space separated list of menu activation keys
- `multiword` defines whether the expansion should use several words or not
- you can provide a space separated list of activation keys that should support multi-word matching
- `name` is the name of the key used when merging a match's text into a URL
- `src` the path or URL to retrieve options from
- `data-turbo-frame` used to identify which [`<turbo-frame>`][turbo-frame] element to
navigate when the menu of options is active
## Events
**`trix-mentions-change`** is fired when a key is matched. In `event.detail` you can find:
- `key`: The matched key; for example: `@`.
- `text`: The matched text; for example: `cat`, for ``.
- If the `key` is specified in the `multiword` attribute then the matched text can contain multiple words; for example `cat and dog` for ` and dog`.
- `provide`: A function to be called when you have the menu results. Takes a `Promise` with `{matched: boolean, fragment: HTMLElement}` where `matched` tells the element whether a suggestion is available, and `fragment` is the menu content to be displayed on the page.
> **Warning**
>
> Event listener callbacks are synchronous and will not block to wait for
> `Promise` resolution. If your `provide` callback is asynchronous, make sure
> to chain additional `Promise` instances with [Promise.then][], or make sure to
> nest any [`async` or `await`][async] keywords _within_ the callback function
> passed to `detail.provide`.
[Promise.then]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/then
[async]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/async_function
```js
const expander = document.querySelector('trix-mentions')
expander.addEventListener('trix-mentions-change', function(event) {
const {key, provide, text} = event.detail
if (key !== '@') return
const suggestions = document.querySelector('.emoji-suggestions').cloneNode(true)
suggestions.hidden = false
for (const suggestion of suggestions.children) {
if (!suggestion.textContent.match(text)) {
suggestion.remove()
}
}
provide(Promise.resolve({matched: suggestions.childElementCount > 0, fragment: suggestions}))
})
```
The returned fragment should be consisted of filtered `[role=option]` items to
be selected. Any attribute whose name it prefixed by `data-trix-attachment-`
will transformed into camelCase and used to create a [Trix.Attachment][]
instance under the hood. For example:
```html
<ul class="emoji-suggestions" hidden>
<li role="option" data-trix-attachment-content="🐈"
data-trix-attachment-content-type="application/vnd.my-application.mention">
🐈
</li>
<li role="option" data-trix-attachment-content="🐕"
data-trix-attachment-content-type="application/vnd.my-application.mention">
🐕
</li>
</ul>
```
Alternatively, `Trix.Attachment` options can be serialized into a JSON object
and encoded into a single `[data-trix-attchment]` attribute. Additional
`data-trix-attachment-` prefixed attributes will be merged in as overrides.
When the `Trix.Attachment` options are missing a `content` key, the selected
`[role="option"]` element's [innerHTML][] will serve as the `content:` value.
[Trix.Attachment]: https://github.com/basecamp/trix/tree/1.3.1#inserting-a-content-attachment
[innerHTML]: https://developer.mozilla.org/en-US/docs/Web/API/Element/innerHTML
**`trix-mentions-value`** is fired when an item is selected. In `event.detail` you can find:
- `key`: The matched key; for example: `@`.
- `item`: The selected item. This would be one of the `[role=option]`. Use this to work out the `value`.
- `value`: A null value placeholder to replace the query. To replace the query text, re-assign this value.
```js
const expander = document.querySelector('trix-mentions')
expander.addEventListener('trix-mentions-value', function(event) {
const {key, item} = event.detail
if (key === '@') {
const contentType = item.getAttribute('data-trix-attachment-content-type')
const content = item.getAttribute('data-trix-attachment-content')
event.detail.value = {content, contentType}
}
})
```
Often times, when `[role="option"]` elements encode the `Trix.Attachment`
arguments into their `data-trix-attachment`-prefixed attributes,
`trix-mentions-value` event listeners can be omitted entirely.
## Built-in support for Turbo Frames
All `<trix-mentions>` elements have built-in support for driving
[`<turbo-frame>` elements][turbo-frame].
First, render them with a `[name]` attribute to serve as the query parameter
key, and a `[data-turbo-frame]` attribute that references a `<turbo-frame>`
element with a matching `[id]` attribute:
```html
<trix-mentions key="@" name="query" data-turbo-frame="users">
<trix-editor></trix-editor>
</trix-mentions>
<turbo-frame id="users" role="listbox" hidden></turbo-frame>
```
Make sure to render the `<turbo-frame>` with the `[hidden]` attribute to start.
Then, whenever a `trix-mentions-change` event is dispatched that bubbles without
any calls to `CustomEvent.detail.provide`, the `<trix-mentions>` element will
merge its current match's text into its `[src]` attribute (using the `[name]`
attribute as its key) then write that value to the `<turbo-frame>` element's
`[src]` attribute. It'll wait for the [FrameElement.loaded][] promise to resolve
before proceeding. When the `<trix-mentions>` element's `[src]` attribute is
missing, it'll merge the name-value pair directly into the `<turbo-frame>`
element's `[src]` attribute's search parameters.
Finally, it'll manage the `<turbo-frame>` element's `[hidden]` attribute and
keep it synchronized with the visibility of the expanded list of options.
[turbo-frame]: https://turbo.hotwired.dev/handbook/introduction#turbo-frames%3A-decompose-complex-pages
[FrameElement.loaded]: https://turbo.hotwired.dev/reference/frames#properties
## Trix-powered Action Text mentions
Inspired by [Implementing rich-text mentions with Action Text][] by George
Claghorn
The `<trix-mentions>` element integrates with Action Text attachments to embed
server-generated HTML renderings of Active Record instances.
Start with a `User` model:
```ruby
class User < ApplicationRecord
scope :where_username_like, ->(text) {
if text.present?
where("username LIKE ?", text + "%")
else
none
end
}
end
```
Include the [ActionText::Attachable][] module into the class:
```diff
class User < ApplicationRecord
+ include ActionText::Attachable
+
scope :where_username_like, ->(text) {
if text.present?
where("username LIKE ?", text + "%")
else
none
end
}
end
```
Action Text will generate the HTML for an attached `User` record by rendering
the partial name it returns from its
`User#to_trix_content_attachment_partial_path` method. By default, that method
will return `users/user`.
If you'd like to render a different partial, override it to return a different
path:
```diff
class User < ApplicationRecord
include ActionText::Attachable
scope :where_username_like, ->(text) {
if text.present?
where("username LIKE ?", text + "%")
else
none
end
}
+
+ def to_trix_content_attachment_partial_path
+ "users/trix_content_attachment"
+ end
end
```
Then, declare the partial's template:
```erb
<%# app/views/users/_trix_content_attachment.html.erb %>
<span>@<%= user.username %></span>
```
The record instance will be available under a key that matches its class name.
In this case, `user`:
Next, render the `<trix-mentions>`. In this example, we'll nest a
`<trix-editor>` element to serve as its input, and a `<turbo-frame>` element to
serve as its listbox:
```erb
<trix-mentions key="@" name="query" data-turbo-frame="users">
<trix-editor></trix-editor>
<turbo-frame id="users" role="listbox" hidden>
</turbo-frame>
<trix-mentions>
```
Within the `<turbo-frame>` element, render a list of `[role="option"]` elements
that match the value of `params[:query]`:
```diff
<trix-mentions key="@" name="query" data-turbo-frame="users">
<trix-editor></trix-editor>
<turbo-frame id="users" role="listbox" hidden>
+ <% User.where_username_like(params[:query]).each do |user| %>
+ <button id="<%= dom_id(user, :mention) %>" type="button" role="option" tabindex="-1">
+ <%= render user.to_trix_content_attachment_partial_path, user: user %>
+ </button>
+ <% end %>
</turbo-frame>
<trix-mentions>
```
Then, encode the [`User#attachable_sgid`][attachable_sgid] into the element's
`[data-trix-attachment-sgid]` attribute:
```diff
<trix-mentions key="@" name="query" data-turbo-frame="users">
<trix-editor></trix-editor>
<turbo-frame id="users" role="listbox" hidden>
<% User.where_username_like(params[:query]).each do |user| %>
- <button id="<%= dom_id(user, :mention) %>" type="button" role="option" tabindex="-1">
+ <button id="<%= dom_id(user, :mention) %>" type="button" role="option" tabindex="-1"
+ data-trix-attachment-sgid="<%= user.attachable_sgid %>">
<%= render user.to_trix_content_attachment_partial_path, user: user %>
</button>
<% end %>
</turbo-frame>
<trix-mentions>
```
If the `Trix.Attachment` instance requires more attributes, you can encode their
values under kebab-case key names with a `data-trix-attachment-` prefix, or as a
single JSON-encoded object under the `[data-trix-attachment]` key:
```diff
<trix-mentions key="@" name="query" data-turbo-frame="users">
<trix-editor></trix-editor>
<turbo-frame id="users" role="listbox" hidden>
<% User.where_username_like(params[:query]).each do |user| %>
- <button id="<%= dom_id(user, :mention) %>" type="button" role="option" tabindex="-1">
+ <button id="<%= dom_id(user, :mention) %>" type="button" role="option" tabindex="-1"
+ data-trix-attachment="<%= html_escape { sgid: user.attachable_sgid, content_type: "..." }.to_json %>">
<%= render user.to_trix_content_attachment_partial_path, user: user %>
</button>
<% end %>
</turbo-frame>
<trix-mentions>
```
Then, declare a partial template for Action Text to render when it encounters
attached `User` instances. By default, Action Text will attempt to render
`users/user`, but that partial path can be overridden by declaring
`User#to_attachable_partial_path`:
```diff
class User < ApplicationRecord
include ActionText::Attachable
scope :where_username_like, ->(text) {
if text.present?
where("username LIKE ?", text + "%")
else
none
end
}
def to_trix_content_attachment_partial_path
"users/trix_content_attachment"
end
+ def to_atachable_partial_path
+ "users/attachable"
+ end
end
```
Finally, declare the template to render an attached `User`:
```erb
<%# app/views/users/_attachable.html.erb %>
<%= link_to user do %>
<%= render user.to_trix_content_attachment_partial_path, user: user %>
<% end %>
```
[ActionText::Attachable]: https://edgeapi.rubyonrails.org/classes/ActionText/Attachable.html
[Implementing rich-text mentions with Action Text]: https://gist.github.com/georgeclaghorn/c83b31a7e378fb07fba0c3d25835e5ba
[attachable_sgid]: https://edgeapi.rubyonrails.org/classes/ActionText/Attachable.html#method-i-attachable_sgid
## Browser support
Browsers without native [custom element support][support] require a [polyfill][].
- Chrome
- Firefox
- Safari
- Microsoft Edge
[support]: https://caniuse.com/#feat=custom-elementsv1
[polyfill]: https://github.com/webcomponents/custom-elements
## Development
```
npm install
npm test
```
Please read [CONTRIBUTING.md](./CONTRIBUTING.md).
## License
Distributed under the MIT license. See LICENSE for details.