Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Admin Interactivity

The admin is server-rendered HTML with htmx. Behaviour htmx cannot express is an island, registered by name.

Request feedback

Every htmx request shows a progress bar, dims the submitting form, and raises a toast on failure. A 422 re-renders the form with its errors. See Validation.

Raise a toast from script:

window.lat.flash('Import finished.', 'success');   // or 'error', which stays until dismissed

Keyboard

KeyWhereDoes
EnterA single-line fieldSubmits the form.
EnterA textareaA newline.
Cmd/Ctrl+EnterAnywhere in a formSubmits it.
EnterA picker’s search box, a repeater rowActs there: the picker chooses what is highlighted; a repeater row moves to the next field, and from its last field adds a row. Never submits the form around it.
Down, Up, Home, EndA picker’s listMove the highlight. Down on a closed list opens it.
EscapeA menu, a dialogCloses it.

Opt out in a descriptor:

form:
  enter: off               # Enter never submits this form; Cmd/Ctrl+Enter still does
  fields:
    sku: { enter: next }   # Enter moves to the next field
    notes: { type: textarea }

A widget of your own that puts an input inside a form marks its root data-lat-enter-scope, with data-lat-enter-target="<selector>" naming the button Enter presses there; without a target, Enter does nothing.

Lists

ControlBehaviour
Column headerSorts; a second click flips it. Only a declared, sortable column sorts.
ColumnListColumn builders: sortable(false), invisible(), width("10%"), align(Align::Right), require(permission), labels(..). A column the operator may not see is not queried, sorted, searched or exported.
A stored codelabels: [{ value: login_success, label: Signed in }] shows the label for the value, whatever the column’s type. A value without one shows as stored.
RowOpens its record on a click or Enter when the list links rows to a form. A modified or middle click opens a new tab; a click on the row’s own controls is theirs. row_click: none leaves it to the Edit link.
Row boxesShift and a click ticks, or clears, every box between it and the last one clicked.
SearchAsks as you type, in the columns marked searchable; text columns by default. ListColumn::searchable overrides. ListConfig::search(SearchConfig) sets a prompt, asks on_enter only, or turns it off().
Empty state“No records yet.”, or ListConfig::no_records_message(text).
Filtersboolean, select, text, number, date, or of(field, label, type). Only a value the type accepts reaches the query. default_value narrows the list until the operator decides; require(permission) gates it.
List setupEach operator chooses which columns show, and the page size. Picking every column clears the choice.
ExportOn a list that calls .exportable(): CSV or JSON of the current query without paging. Over 20,000 rows is refused.
PagerKeeps the sort, search and filters.
Page sizeper_page. per_page_options offers a choice in the list setup, remembered per operator.
The queryThe sort, search and filters an operator used come back when they return to the list from a plain link, within the session; clearing them forgets them. remember: false turns it off.

Every control is a link or a GET form, so it works with scripting off.

Add a toolbar button:

use laterite_admin::list::ToolbarButton;

ToolbarButton::new("Reports", "/reports")
    .icon("history")
    .require("acme.view_reports");

A path starting with / resolves under the admin mount. A button the operator lacks the permission for is not rendered.

Confirm a destructive action

<button type="submit" data-lat-confirm="Delete the selected records? This cannot be undone.">
  Delete
</button>

Ask from script

lat.confirm('Archive 12 articles?', { go: 'Archive', cancel: 'Keep', focus: 'cancel' }, function (yes) {
  if (yes) archive();
});
LabelDoes
goNames the confirming button. Default OK.
cancelNames the other. Default Cancel, localized.
focus'cancel' opens with the cursor on it. Default: the confirming button.

Forms

A form of your own template joins in with one attribute:

<form method="post" action="/admin/acme/import" data-lat-widget="form" data-lat-focus="first">
AttributeDoes
data-lat-widget="form"Asks before leaving with changes unsaved.
data-lat-refusedThe form holds a submission that was not saved: it opens changed, with the cursor in the first field carrying an error.
data-lat-focusfirst opens with the cursor in the first field; off leaves it alone.
data-lat-confirm-leave="off"Never asks.
data-lat-counterOn a control with maxlength: auto or on.
data-lat-grow="off"On a textarea: keeps the height it was given.
data-lat-preset, data-lat-preset-typeOn a field’s wrapper: the field followed and the shape (slug, url, file, exact).
data-lat-trigger-action, data-lat-trigger-field, data-lat-trigger-conditionOn a field’s wrapper: the dependency, as Descriptor Files spells it.

Events

Every island announces what it does as a DOM event named lat:<component>:<event>, bubbling from its root. Server-side facts are on the event bus.

var stop = lat.on('repeater:added', function (detail, event) {
  console.log(detail.count, 'rows; the new one is', detail.row);
});

// A `before-` event is cancelable: this caps a repeater at five rows.
lat.on('repeater:before-add', function (detail, event) {
  if (detail.count >= 5) event.preventDefault();
});

// Drive an island through its controller.
lat.get(document.querySelector('[data-lat-widget="repeater"]')).add();
EventDetailCancelable
repeater:before-addcountYes
repeater:addedrow, index, countNo
repeater:before-removerow, index, countYes
repeater:removedindex, countNo
repeater:movedrow, from, to, countNo
repeater:duplicatedrow, index, countNo
ref-picker:changedid, label, previousNo
ref-picker:clearedpreviousNo
selection:changedids, countNo
checklist:changedvalues, count, totalNo
flash:showntext, levelNo
flash:dismissedtextNo
confirm:openedtextNo
confirm:confirmed, confirm:cancelledNoneNo
copy:copiedvalueNo
reveal:shown, reveal:hiddenNoneNo
preset:filledfrom, valueNo
trigger:changedfield, metNo
ControllerMethods
Repeateradd(), remove(index), move(from, to), duplicate(index), count()
Record picker, searched selectvalue(), label(), choose({ id, label }), clear()
Checklistvalues(), count(), set(values), all(), none(), search(text)
Formchanged(), settle(): takes the form as it stands for saved
Password revealshow(), hide()
HelperDoes
lat.on(name, handler)Listens page-wide, swapped content included. Returns the function that stops listening.
lat.emit(el, name, detail, cancelable)Announces lat:<name> from el. Returns false when a listener cancelled it.
lat.get(el)The controller of the island at or around el.
lat.scan(root)Starts the islands in markup a script added. Swapped content starts without it.
lat.confirm(text, labels, done)Asks in the admin’s dialog. done hears true or false.

Write an island

window.lat.widget('char-count', function (el) {
  var input = el.querySelector('input');
  function count() { return input.value.length; }
  input.addEventListener('input', function () {
    el.querySelector('.count').textContent = count();
    lat.emit(el, 'acme.char-count:changed', { count: count() });
  });
  // Returned, so `lat.get(el).count()` reads it from outside.
  return { count: count };
});

An island that builds markup around its element returns that markup as root, so lat.get finds the controller from inside it too.

<div data-lat-widget="char-count">
  <input type="text" name="title">
  <span class="count">0</span>
</div>

Every matching element is initialised once, on load and after each htmx swap. Bind by structure, not by id. window.lat.assets.ensure(url) loads a stylesheet or script once.

Not built yet

Inline editing in a row; reordering columns.