A cell edits with the editor of its column's type: a text box for text
and numbers, the browser's date and time inputs, a toggle for booleans, a
list for an enum. A column may bring its own instead, with editor:
- a form of HEEx, a function component that the server renders in a popover under the cell. Whatever its form posts is the new value: a Koyomi picker, a Kotoba editor, a search of your users, any HEEx and any LiveView hook. No JavaScript of your own;
- an editor of the browser's, a JavaScript function you give to
configure, drawn over the cell at once: a slider, a color, a tag input.
Either way the grid keeps the rest of an edit as it does for its own
editors: the changeset (or on_update, see Editing), the
errors on the cell, undo and redo, the cells changed meanwhile (a
compare-and-swap), "Ada is editing" for the others, and paste, fill down
and Delete, which go through the column's type, not the editor.
A form of HEEx
<.data_grid id="bookings" source={Booking} repo={Repo} changeset={&Booking.changeset/2}>
<:col field={:guest} />
<:col field={:notes} editor={¬es_editor/1} />
</.data_grid>defp notes_editor(assigns) do
~H"""
<textarea name={@field.name} rows="4" aria-labelledby={@label_id}>{@field.value}</textarea>
"""
endThe component gets:
| Assign | |
|---|---|
field | a Phoenix.HTML.FormField of the cell's value, named cell[value]: what the form posts under it is the new value |
form | its form |
value, row, id | the cell's value and its row, as the source reads them now, and the row's id |
column | the Masu.Column |
label, label_id | the column's label, and the id of an element that has it, for the input's aria-labelledby |
scope | the grid's scope |
What the form posts goes to the changeset as the field's params, as a
form's do: text for cell[value], a map for cell[value][from] and
cell[value][to], a list for cell[value][]. Your Ecto type casts it.
Kotoba and Koyomi
A Kotoba editor or a Koyomi picker posts its value as a form field, so each is one component in an editor of your own:
defp notes_editor(assigns) do
~H"""
<.kotoba field={@field} id={"notes-#{@id}"} label_id={@label_id} features={~w(bold italic links lists)} />
"""
end
defp stay_editor(assigns) do
~H"""
<.date_range_picker field={@field} label_id={@label_id} months={2} />
"""
endKotoba: rich text in cells and Koyomi: pickers for dates, times and ranges have the whole of each: the schema, what the cell shows, paste, filters, tests.
A search of the server
The component renders in the LiveView's process: a Phoenix.LiveComponent
in it searches as the person types, with its own events, and puts the
pick in an input of the field.
defp assignee_editor(assigns) do
~H"""
<.live_component module={MyAppWeb.UserSearch} id={"assignee-#{@id}"} field={@field} scope={@scope} />
"""
endKeys and focus
| Enter, F2, a double click | opens the editor; the first control of the popover has the focus |
| Ctrl+Enter (⌘↩), a submit of the form | saves (Enter in a single-line input submits a form; Enter in a text area, or a rich text editor, is the editor's) |
| A click away | saves |
| Escape | lets the edit go, once the editor's own Escape (a picker's calendar) is done |
| Tab | moves between the controls of the popover |
A form that posts what it posted when it opened saves nothing. The popover is a dialog labelled by the column's label; the focus goes back to the grid when it closes.
An editor of the browser's
Give your editors to configure, in your app's JavaScript, by name:
import { Masu, configure } from "masu"
configure({
editors: {
score: ({ element, value, options, label }) => {
const input = Object.assign(document.createElement("input"), {
type: "range", min: options.min ?? 0, max: options.max ?? 100, value: value ?? 0,
})
input.setAttribute("aria-label", label)
// The arrows are the slider's.
input.addEventListener("keydown", (event) => {
if (event.key.startsWith("Arrow")) event.stopPropagation()
})
element.append(input)
return { value: () => Number(input.value), focus: () => input.focus() }
},
},
})<:col field={:score} type={:integer} editor={{"score", %{max: 100}}} />
<:col field={:color} editor="color" />The function gets a context, and gives the editor:
| The context | |
|---|---|
element | a box over the cell, as wide as the column and as tall as the row, to draw in |
value, text | the cell's value as the browser has it, and the character typed that opened the editor |
id, row | the row's id, and its values by column key |
column, options, label, locale | the column, the options of its editor, its label, the grid's locale |
commit(value, move) | saves a value; move is "down", "up", "right" or "left" |
cancel() | lets the edit go |
| The editor | |
|---|---|
value() | the value now, which the grid saves on Enter, Tab and a click away |
focus() | takes the focus (the first control of element does unless given) |
contains(node) | whether a node outside element is the editor's (a list it put in document.body) |
destroy() | called when the editor closes |
The keys an editor handles, it prevents (event.preventDefault()) or
stops: the grid acts on the others: Enter saves and moves down
(Shift+Enter, up), Tab right (Shift+Tab, left), Escape lets the edit go.
The grid checks the value by the column's type before it saves it, as it
does for its own editors, and the changeset has the last word.
An editor that is not given to configure leaves the column with the
editor of its type, and says so in the browser's console.
Values of your own types: Masu.Wire
A cell shows its value as the browser gets it: numbers, decimals, dates
and times, booleans, text, and structs that implement String.Chars.
For another type, implement Masu.Wire: what the cell shows, what a copy
and an export take.
defimpl Masu.Wire, for: Kotoba.Content do
def to_wire(content), do: content.text
end
# Koyomi.DateRange's values are Date.Range.
defimpl Masu.Wire, for: Date.Range do
def to_wire(range), do: "#{range.first} → #{range.last}"
endThe editor gets the value as the row has it, not this. A column of such
values sorts and filters only as far as its source can: give it
sortable={false} and filterable={false} unless its database column
does.
New rows
A new row, until it saves, has no id for the server to find: its cells edit with the editors of their types.
Testing
view = open_editor(view, "bookings", 1, :notes)
assert has_element?(view, ~s(#bookings-cell-editor textarea[name="cell[value]"]))
assert :ok = edit(view, "bookings", [{1, :notes, "Late check-in"}])
assert :ok = edit(view, "bookings", [{1, :stay, %{"from" => "2027-03-15", "to" => "2027-03-20"}}])open_editor/4 renders the form, as the browser's Enter does;
edit/4 saves what it would post, as the browser does. An editor of the
browser's is tested in the browser.