Regions
A page is a list of regions. A region reads one table and renders it in one shape. Every region declares which table it is about, and can narrow the page’s access rule for itself.
page members "Members" { route: /members access: board
report of members "All members" { columns: name, email, fee_group search: name, email sort: name page_size: 25 }}Each property is written once per block. A second page_size: in the same report, or a
second clear: in the same duplicate block, is a compile error at the second line, and the
message gives the line of the first. A property that takes a list takes it on one line:
columns: name, email, fee_group. This rule applies to every block: app, table, column, page,
region, field, and access rule.
Three region kinds render rows in a table, and they differ in where the columns come from and whether a cell accepts typing:
| Region | Columns come from | Editable | Status |
|---|---|---|---|
report |
the schema | no | shipped |
sheet |
the schema | yes | shipped, save, add, and delete, in an editing grid |
matrix |
the data | not yet | shipped, read-only |
Regions that read
Section titled “Regions that read”report
Section titled “report”Rows of one table, for reading. A report supports search, filters, sortable columns, pagination, export, row links, and an empty state.
| Property | Meaning |
|---|---|
columns |
which columns, in which order |
search |
columns that one search box searches |
filter |
columns offered as filters |
sort |
the initial order |
page_size |
rows per page. Without it, a report shows every row. |
where |
a fixed condition on top of the access rule |
export |
any of csv, xlsx, and ics, comma separated |
export_name |
the name in the export’s address. Default: the region’s table. |
export_button |
true puts the downloads under their own Export control. Default false: they are in the menu of the first button. |
striped |
true draws every second row on a faint background. Default false. |
empty |
the text shown when there are no rows |
striped: true is the one property that changes only how a report looks.
report of members "Members" { columns: last_name, first_name, fee, joined striped: true}Use it on a wide table of short values: a band keeps the eye on one row while it reads across. Leave it off for a short table, or one whose cells hold sentences — there the band is noise. No rule can tell the two apart, so the engine does not guess.
striped: belongs to a report and to nothing else. A list draws cards and a detail draws
one record, so neither has rows to alternate; the compiler refuses the property there and names
the region that draws a table.
The toolbar shows one action
Section titled “The toolbar shows one action”A report draws one row between its heading and its table. The search box and the filters are on the left, and the region’s actions are on the right. When the report has no search box and no filter, the actions are in the region’s heading.
The filters are behind one Filters button. It opens a panel with one control for each
column in filter:, and Apply reloads the list. The control depends on the column’s type:
| Type | Control | Rows shown |
|---|---|---|
enum, ref |
a checkbox for each value | rows with any of the ticked values. No tick shows all rows. |
date |
From and To date fields | rows from the first day to the last day, both included. Leave one field empty to leave that end open. |
bool |
All, Yes, or No | all rows, only yes, or only no |
| other types | a text field | rows whose value is equal to the text |
Two filters narrow together: a row must match both. A filter’s address is part of the page’s
address, so a filtered list can be bookmarked. ?status=active&status=passive ticks two
values, and ?joined_on.from=2026-01-01&joined_on.to=2026-03-31 sets a range. The engine
ignores a value that the column cannot hold.
Each filter that is set shows as a chip
next to the button, for example Status: Active ×, and the button shows how many are set. A
chip is a link to the same list without that filter: the search, the sort, and the other
filters stay. Clear filters in the panel removes every filter and keeps the search. Enter
in the search box submits the search together with the filters that are set.
The first button is the region’s primary action, and it is always visible. Every other
action is in a menu that opens from the arrow beside it: the other buttons first, then the
downloads.
report of members "Members" { export: csv, xlsx
button "New member" -> member button "Invite" -> invitation}This report shows New member with an arrow. The arrow opens a menu with Invite, CSV export, and Excel export. A report with only one button and no export shows the button without an arrow.
Write export_button: true to give the downloads their own Export control beside the
primary action. Use it where readers download often, for example a report that somebody
exports every month.
report of members "Members" { export: csv, xlsx export_button: true
button "New member" -> member}With one download format, the control is a single link, such as CSV export. With two, it is
an Export menu. A report without a button always shows its downloads in this way.
export_button: needs export: csv or export: xlsx. A calendar feed (export: ics) has its
own block under the heading, so the compiler refuses export_button: on a region whose only
export is ics.
The menus open and close without JavaScript. With JavaScript, Escape closes a menu, and so does a press outside it.
Export formats
Section titled “Export formats”export: csv and export: xlsx each draw a download control. Neither implies the other. A CSV
is the format a program reads; a workbook is the format a person opens, and its cells carry a
type, so a number adds up and a date sorts in every locale. See
Export a listing.
An export’s address is the page’s own address with ?mtd_export=<name> for a CSV and
?mtd_export=<name>.xlsx or ?mtd_export=<name>.ics for the other two. The name is the
region’s table, so report of members { export: csv } is at ?mtd_export=members. The name
does not change when you add a note or a region above the listing. This matters for a calendar,
because a phone asks for the same address for years.
Write export_name: only when one page has two exports of the same table. The compiler refuses
such a page and names the lines:
list of events "Upcoming" { where: starts_at >= now export: ics at: starts_at}
list of events "Past" { where: starts_at < now export: csv export_name: past}The first list is at ?mtd_export=events.ics and the second at ?mtd_export=past.
export_name: is one name without quotation marks. It cannot be a number or contain a dot, and
it needs export:.
With export: ics, ten more properties say how a row becomes a calendar entry. Each one
names a column of the region’s table, except alarm_before, which may read a value from the
page, url, which takes one word, and url_note, which takes a sentence.
| Property | Column kind | Entry |
|---|---|---|
at |
date or datetime. Required with export: ics. |
when it starts |
ends |
the same kind as at |
when it ends |
place |
text |
the location |
details |
text, long text, or markdown |
the description |
repeat |
three columns: an enum over none/daily/weekly/monthly/yearly, an int, a date |
the repeating series, as one rule |
all_day |
bool |
a true row is a day with no time |
alarm |
bool |
a true row may remind the reader. Absent means every row may. |
alarm_before |
an int column, or :<data row>.<int column> |
minutes before. 0, a negative number, or nothing sends no reminder. |
url |
the word page |
the entry links back to the page |
url_note |
a sentence in quotation marks | written above that address in the description |
A reminder is sent only when alarm and alarm_before both say yes. alarm: without
alarm_before: is a compile error. all_day: beside a date at: is a compile error too. A
date has already said that every row lasts all day. url_note: without url: is a compile
error as well: there is no address for the sentence to introduce.
Status: a reminder in a subscribed calendar is honoured by Apple Calendar, ignored by Google Calendar, and handled differently by Outlook versions. See Export data for what to tell your readers.
The title of an entry is the table’s display. Which rows are in the calendar is the
listing’s own query: the page’s access rule, the region’s where, and the reader’s filters.
Every column in columns is sortable. Sorting and filtering go through the compiled model.
A request parameter never reaches SQL as text.
A row -> link and a row_action link are drawn only where the reader can open the target
page with that row. The engine checks the target page’s access rule once, and the target’s row
conditions for each row: its access filter, its data … where, and the access rule of a form
or detail on it. The report still lists a row that the target page would not show. The row
has no link, and its identity cell is plain text.
A multi and a list cannot be columns. Both are sets of rows rather than values. Count one
instead, or ask whether it is empty.
The same rows as a report, in a layout for reading rather than for administration. A list has no search box and no filters. Use a list for a public page, and a report for a screen that somebody works in.
detail
Section titled “detail”One row, as a record. A detail shows fields in a metadata line rather than in cells, so it
can show a multi or a list as a set of links. It is the right region for an article, a
member file, or any page that is about one row.
Counts as tiles. A stats region groups a table and shows the totals.
matrix
Section titled “matrix”A crosstab whose columns are generated from the data. The rows come from one table, the columns from another, and each cell is a row of a junction table.
matrix of grades "Grades" { rows of enrolments { where: course = :course.id label: student } columns of assessments { where: course = :course.id label: name } cell: points key: enrolment, assessment empty: "—"}key: names two ref columns of the cell table, one for each axis. The compiler checks
exactly that.
A matrix is read-only today. Writing a cell needs upsert-on-key, because a cell addresses a row that can be absent.
NOTE: Both axes cannot read the same table. The two keys do not say which axis they address, and source order is not allowed to decide it. The compiler refuses that shape and names the reason.
versions
Section titled “versions”The history of the page’s record: every version of its versioned
columns, newest first.
page post "Post" { route: /posts/{id} access: editor, author
data post of posts
form of posts { fields: title, body }
versions of posts "History" { access: editor }}A versions region takes access: and nothing else. Its record is the page’s, and its columns
are the table’s versioned columns. The compiler refuses it when the table keeps no versions, and
when the page’s route does not bind a record of the table.
Each version shows:
| Column | Shows |
|---|---|
| Saved | the time of the save, in the page’s time zone |
| By | the person’s profile name, the role when there is no profile, “A former user” when the login was deleted, and “A visitor without a sign-in” for a save without one. Never an e-mail address |
| Changed | the versioned columns that differ from the version before |
| Status then | the record’s lifecycle state after the save, on a table with a lifecycle |
Compare shows the change from the version before at ?mtd_compare=<a>,<b>, as an
ordinary page. Text, long text, Markdown, and e-mail columns are compared line by line. A
Markdown column compares its source text. Each line is marked with <ins> or <del>, a + or
− sign, and the word “Added” or “Removed” for a screen reader, so the colour is never the only
signal. Other columns show the value before and after. When two texts are too different to
compare within the limits (20,000 lines on a side, or 600 changed lines), the two texts stand
side by side instead.
Restore opens the page’s own form at ?mtd_restore=<version>. The versioned fields hold the
version’s values, and every other field holds the current value. Nothing changes until the
person presses Save, and the save is an ordinary save: every rule, deny when, and hook runs,
a ref must name a record the person may choose, and a Markdown image must be a picture they
may open. The save adds a new version; it never rewrites an old one.
If somebody saved the record after the restore form was opened, the first Save is refused with a message, and the form comes back with what was typed. Pressing Save again restores anyway.
Seeing and restoring are two permissions.
- Seeing needs the region’s
access:and the page’s record. A region without its ownaccess:follows the page’s rule, except that a visitor without a sign-in never sees it. Writeaccess: publicto show a history to everybody. - Restoring also needs a form on the page that edits this record for this person and has a
versioned field that is not
readonly:. Without one, the Restore link is not drawn and the address answers 404.
A version of another record, like a version that does not exist, answers 404. So does a malformed address.
Regions that write
Section titled “Regions that write”One row, with a control per field. A form is the region for creating and editing.
| Property | Meaning |
|---|---|
fields |
which columns, in which order. Without it, every stored column except a slug, an unguessable, a derived column, the lifecycle state and the audit columns |
actions |
any of save, delete, duplicate |
readonly |
fields shown as values rather than controls |
success |
the message after a save |
A save is a POST, then a redirect, then a GET. The message survives the redirect, so a reload never repeats a write.
A field whose storage the engine owns is never a control. A control that the write path will refuse can only lose somebody’s work.
A form open to visitors cannot show a ref control
Section titled “A form open to visitors cannot show a ref control”A ref control lists up to 200 rows of the referenced table by their display: text. A table
has no read rule of its own, so a form on a public page would show those names to every
visitor. The compiler therefore refuses a form that a visitor with no login can open when it
shows a ref control. This applies to create forms and to edit forms on a token route.
A ref does not count as a control when a set fills it or when it is in readonly:. Fill it
from the page’s row, which is the usual case for a sign-up:
form of event_signups { fields: guest_name, guest_email, note set event = :event.id}The other repairs are to remove the field or give the form a rule that needs a login, for example
form of event_signups { access: board … }. The engine refuses the same model again when the
organization’s file is opened.
NOTE: A multi field on a public form is not refused yet. Its boxes list every row of the
referenced table, up to 200. Put a multi on a public form only when every row of that table
may be public.
actions: duplicate and the duplicate block
Section titled “actions: duplicate and the duplicate block”duplicate adds a link that opens the page’s new_route: with the values of the row on
screen. The action is opt-in: a form that writes no actions: gets save and delete.
form of events { fields: title, starts_at, ends_at, place, capacity actions: save, delete, duplicate
duplicate { clear: starts_at, ends_at label: "Copy as template" }}| Property | Meaning | Default |
|---|---|---|
clear |
columns the copy leaves empty, in addition to the ones it never carries | none |
label |
the link’s text | an engine word |
A copy fills in the controls the form has. It therefore carries no engine-owned column, no
lifecycle state, no set column, no derived column, and no sensitive column the role may
not read. It also leaves a readonly: field, a picture, and a column that is unique on its
own empty. A column that is unique only in combination is carried.
clear: only removes. Naming a column the copy would not have carried is a compile error.
The link opens an ordinary page: a record the reader may not see answers 404, and the save
that follows is an ordinary create. The compiler refuses duplicate on a page with no
new_route:, on a route that carries scope, on a page about no record, and on a form that
writes a different table from the one the page is about. See
Duplicate a record.
A field block overrides one column’s presentation for this form. It accepts add_via,
empty_label, help, label, min, max, quick_picks, rows, show_when and suggest.
form of events { fields: title, alliance
field alliance { empty_label: "All alliances" }}A default: and a value: are refused there. Both are data, so both stay on the column. See
Column types for each property.
Declare each column in one field block. A form resolves a field by its first block, so a
second block for the same column never reaches the page. The compiler refuses the second block
and names the line of the first. Put every property for one column together:
field ends_at { suggest: starts_at + 1h help: "Enter the date and time in UTC."}show_when: hides a field in the browser
Section titled “show_when: hides a field in the browser”show_when: takes a condition over the form’s own fields. While the condition is false, the
browser hides the field.
form of events { fields: title, repeat, every, until
field every { show_when: repeat != none } field until { show_when: repeat != none }}The condition uses the same expressions as where: and check. The engine sends it to the
browser as data, and one engine-owned script reads it. An application supplies no JavaScript.
The hidden field keeps its control, its name and its value. A save therefore sends the same
values whether the reader has JavaScript or not. The server does not read the condition: every
check, require ... when, set, deny when and hook sees the same request.
A reader with JavaScript turned off sees all fields. Nothing is hidden, and the form still saves.
show_when: accepts a smaller set of conditions than where:, because the browser must give
the same answer as the database.
| You can write | You cannot write |
|---|---|
| a field of this form, or a fixed value of that field’s type | a path such as alliance.name, :route/:user/:app, today, now, a function, a count, a computed column |
a source column of type enum, bool or int |
text and long text (the engine compares text by Unicode rules itself, and a browser compares bytes), date, datetime and money (the control holds a spelling and the server holds the value) |
a ref with is empty or is not empty |
ref = <value>, because the value would be a row id |
=, !=, is empty, is not empty, and, or, not |
<, <=, >, >= |
Each refusal names the repair. Three more conditions are refused:
- A
requiredcolumn cannot be hidden. The browser refuses to save a hidden empty required control, and the reader sees no message. Make the column optional and writerequire <column> when <condition>on the table. The same condition then runs on the server. - A field cannot name itself. A control that hides itself cannot be shown again.
show_when:works in aformonly. In asheetor areport, one field is a column of many rows.
An empty value never makes a comparison true. repeat != none is false while repeat is
empty, both on the server and in the browser. Write is empty to test for an empty value.
If a sensitive column is not shown to this role, the condition that reads it is not sent, and
the field stays visible. See Control who sees what.
add_via: creates the row a ref field is missing
Section titled “add_via: creates the row a ref field is missing”add_via: names the page that creates a row of the table a ref field points at. The engine
draws a control beside the dropdown.
form of events { fields: title, category
field category { add_via: admin_category }}The reader opens it, fills in the new category, and saves. The category becomes the selected option, and everything they had already typed in the event form is still there.
The named page decides everything about that save: who may make one, which fields they see,
what a refusal says. add_via: adds none of it. So a reader who may not open the create page
sees no control at all — not a disabled one, and no message. The field itself is unchanged.
In a browser the page is fetched without its frame and opened as a dialog. With JavaScript turned off the control is a link to that page, the reader creates the row there, and comes back. That is the same walk they take without the declaration, so nothing depends on the script.
Each of these is a compile error, and each names the repair:
- the field is not a
ref— amultihas no single option for a new row to become; - the page does not exist;
- the page has no
new_route:, so there is no address a row is created at; - the
new_route:carries a route parameter, and the form that opens it has no value for it; - the page’s form writes a different table from the one the
refpoints at; - the page has no form at all;
- the page is the page that declares
add_via:; add_via:is written outside aform.
suggest: offers a value for an empty field
Section titled “suggest: offers a value for an empty field”suggest: fills an empty datetime field from another datetime field of the same form.
form of events { fields: title, starts_at, ends_at
field ends_at { suggest: starts_at + 1h }}Write one field of this form, a +, and one duration. A duration is a whole number with one
unit: min, h or d. Write 30min, 1h or 7d. There is no compound spelling, so write
90min and not 1h30min. The shortest duration is 1min and the longest is 7d.
The engine sends the field name and the number of minutes to the browser as data. One engine-owned script reads them. An application supplies no JavaScript.
Three rules decide when the browser writes a value:
- On a new row only. The engine sends the hint on the page that creates a row. It never sends it on a page that edits one. An empty end time on a stored row is an answer somebody already gave.
- Into an empty field, or into the value the script wrote last. A value somebody typed stays. If the reader clears the field, the field is empty again, and the next change to the source fills it again.
- On the spelling, not on an instant. The script adds minutes to the wall clock in the control. It does not know the page’s time zone. The server still decides which instant that wall clock names. See Set the time zone.
A reader with JavaScript turned off sees an empty field and fills it in. The form saves the same values either way.
Each of these is a compile error, and each names the repair:
- The field it fills, or the field it reads, is not a
datetime. Adatehas no hours, andsuggest:over adateis not built. - The field suggests from itself.
- The form lists the source under
readonly:, fills it with aset, or leaves it out offields:. - The duration is
0min, is subtracted, or is longer than7d. - The
suggest:is in asheetor areport. A value written into the same column of forty rows is a bulk edit and not an offer.
If a sensitive source column is not shown to this role, the engine sends no hint, and the
field is an ordinary empty control.
quick_picks: puts a value into a field with one press
Section titled “quick_picks: puts a value into a field with one press”quick_picks: shows small buttons under a field. When the reader presses one, the browser
writes its value into the field. The reader can still change the value, and the form’s Save
button saves it.
form of events { fields: title, starts_at, location, minutes
field starts_at { quick_picks: now } field location { quick_picks: "Kingdom", "Castle" "The castle" } field minutes { quick_picks: 15, 30 "Half an hour" }}Write a value, and then the text of the button if you want one. Separate the picks with commas.
If you do not write a text, the engine names the button: now shows “Now”, today shows
“Today”, an enum member shows its label, and any other value shows itself.
The field’s type decides which values you can write:
| Field | Value |
|---|---|
datetime |
now |
date |
today |
enum |
the name of a member |
int |
a whole number |
text, long text, email |
quoted text |
now and today read the clock when the reader presses the button, not when the page
loaded. They use the time zone of the page, not the time zone of the reader’s device. On a page
in UTC, now writes the current UTC time. See Set the time zone.
On an enum, now and today are member names.
The buttons appear on the page that creates a row and on the page that edits one. They need JavaScript. A reader with JavaScript turned off sees no buttons and types the value. The form saves the same values either way, and the server checks a picked value like a typed one.
Each of these is a compile error, and each names the repair:
- The field is a
bool, aref, amulti,money, or another type not in the table. - A value does not fit the field’s type, for example
todayon adatetime. - An enum pick names a member that the enum does not have.
- The same value appears twice.
- A value is a duration, a
:user…or:route…parameter, or a path. - A button text is empty.
- The form does not show the field, or lists it under
readonly:. - The
quick_picks:is in asheetor areport.
An int pick outside the field’s min: or max: compiles. The save refuses it with the same
message as a typed value.
A table that a person edits like a spreadsheet, with one save for every change. Use a sheet when writing one row at a time is the wrong shape, such as entering a mark for every student in a class.
sheet of members "Maintain members" { columns: name, email, fee_group, active where: left_on is empty sort: name page_size: 50 actions: save, add, delete row -> member}| Property | Meaning |
|---|---|
columns |
which columns, in which order. Required. |
page_size |
rows per page of the list that a reader without JavaScript gets. Required. |
row -> <page> |
the page whose form edits one row. Required. |
actions |
any of save, add, and delete. Default save. |
search, filter, sort, where, and empty are a report’s and mean the same thing. A
sheet’s rows are the listing’s rows, from the same query. export: and limit: are refused in
a sheet, each with the line to write instead.
A read region can default its columns and leave its rows unbounded. A write region cannot:
defaulting columns: makes every column of the table editable, including columns that the
author never named.
row -> must name a page that holds a form of the sheet’s own table, on a route that carries
the row. The compiler refuses a target that only shows the row, and a form that creates a new
row. The target is never derived.
How a sheet renders
Section titled “How a sheet renders”A sheet has two renderings, and you declare neither.
- With JavaScript, the sheet is an editing grid. The grid loads the rows 200 at a time while the reader scrolls, so it holds every row of the list and draws no pager.
- Without JavaScript, the sheet is exactly a report: the table, the search box, the filters,
the sort links, and a pager of
page_size:rows. No cell is editable. The identity cell of each row links to therow ->page, where its form edits that one row. - On a coarse pointer below 40rem, such as a phone, the grid does not mount, and the reader gets the list with its row links. The engine asks both questions once, when the page loads.
A column’s help: is shown in the grid. The column’s header has a help button with a
question-mark icon. Press it to open the text below the header, and press it again, press
Escape, or press anywhere else to close it. A screen reader reads the text as the button’s
description, so the reader does not have to open it. The list without JavaScript does not show
help:.
A row link is drawn only when the reader can open the target page with that row, as in a report. A row that the target page would not show stays in the list without a link. So a sheet that shows an author every note of a folder links only her own notes, if the note page shows an author only her own.
The page accepts a save only as the grid’s JSON request. A form-encoded save of a sheet is refused before anything in it is read.
What one save does
Section titled “What one save does”One list of columns decides which cells are editable. A cell is read-only when the engine owns the column, when the column is derived, or when a role’s rule does not grant it. A save that names a read-only column does not write it.
One save is one transaction. If any cell is wrong, nothing is written, and the grid shows each message on its cell and keeps every edit. The grid sends only the rows that somebody changed. The engine compares each sent row with storage and writes only the rows that really changed.
Each row carries a stamp. If a row changed after the grid loaded it, the save is refused and names the rows that moved. A sheet is the screen two people open at once, so a silent overwrite is the wrong default.
One save carries at most 200 rows. If a before hook runs on each row, one save carries 50
rows. The count is the changed rows, the removed rows, and the new rows together. A
before delete hook counts only when actions: includes delete, and a before insert hook
only when it includes add. A save over the limit is refused before anything is written, and
the grid says so before it sends the save. page_size: is independent of this limit.
actions: delete lets the reader mark rows for removal: the Delete rows button, or the
same entry in the right-click menu. The save removes the marked rows first, in the same
transaction as the cell changes. A marked row is not checked against any rule of its table.
The row stamp still applies: a marked row whose values changed is refused rather than removed.
A removal that another table’s reference blocks is refused with a message that names the row.
The slug of a removed row stays reserved.
actions: add lets the reader add rows: the Add row button, Insert row in the
right-click menu, or a paste past the last row. One save can create any number of new rows. The
engine creates them last, in the order they were sent, in the same transaction as the cell
changes and the removals. If one new row is refused, none of them is created.
A new row starts with each column’s default:, the default lifecycle state, and the
application’s scope column. A set on the region fills its column too. A read-only cell stays
empty until the save.
A new row whose cells all still hold their defaults is not a request. The grid does not send it,
and the engine drops it when it arrives. A new row with one cell filled in is a row somebody
started. Every rule of the table applies to it, and each message comes back on its cell. A
message about the row as a whole names it, for example “New row 3”. Two new rows with the same
unique value are refused on the later row.
An empty sheet shows its empty: text above the grid, and the engine’s own sentence for a
filter that matched nothing. A sheet with add still offers the grid, so that somebody can
create the first row.
A sheet with add must be able to fill every required column of its table. Four things
satisfy that: the column is writable in columns:, it has a default:, the region has a set
for it, or the engine owns it. A sheet that leaves one required column unsatisfiable is refused
when the tenant is opened. The message names the column and all four repairs.
A sheet whose where: reads a route parameter cannot use add, and the engine refuses it
when the tenant is opened. The new row has no cell for the column that the condition is
about, so every added row would be refused. Create the row in a form on its own page
instead.
The message after a save appears above the grid. It is built from up to three counts, in one order: rows changed, rows added, rows deleted. A count of zero is left out. A save that did none of the three says so.
The grid keyboard
Section titled “The grid keyboard”You declare nothing for the grid. No property turns it on, and no property turns it off.
The grid has two modes. In navigate mode the arrow keys move between cells. Typing, F2, or a double click starts an edit in the active cell, and the arrow keys then belong to the text. Typing replaces the value. F2 and a double click keep it. Escape discards the edit. Enter and Tab commit the edit and move.
| Key | Effect in navigate mode |
|---|---|
| Arrow keys | move one cell |
| Shift + arrow keys | extend the selected range |
| Ctrl + arrow keys | jump to the last filled cell before an empty one, or to the edge |
| Home, End | the first and the last cell of the row |
| Ctrl + Home, Ctrl + End | the first and the last cell of the grid |
| PageUp, PageDown | one screen of rows up and down |
| Tab, Shift + Tab | the next and the previous cell. Tab wraps to the next row and stops at the last cell. |
| Enter, Shift + Enter | one row down and up |
| F2 | edit the cell and keep its value |
| Space | tick or clear a checkbox cell. In another cell, start an edit with a space. |
| Ctrl + A | select every cell |
| Ctrl + C, Ctrl + X, Ctrl + V | copy, cut, and paste the range |
| Delete, Backspace | clear the selected cells |
| Ctrl + Z | undo the last step |
| Ctrl + Y, Ctrl + Shift + Z | redo it |
On a Mac, use Command where the table says Ctrl.
The row-number column at the left selects whole rows. Click a number to select its row, or drag down the numbers to select several rows. The corner above the numbers selects every cell. A right click opens a menu with Insert row, Delete rows, Clear contents, Undo, and Redo.
Copy and paste work with Excel. A copy writes tab-separated text with Excel’s quoting, so a
cell that holds a tab or a line break survives the round trip. A paste reads the same format.
A paste never writes a read-only cell, and columns past the last column are not pasted. Rows
past the last row are added when the sheet offers add, and are not pasted when it does not.
The line under the grid reports how many cells were pasted and how many rows were added.
A dropdown cell copies its label, not the stored value behind it. A paste matches the text
to an option’s label exactly, including upper and lower case, and then to its stored value. If
neither matches, the cell keeps the value it had. The line under the grid names that cell, for
example Status has no option called Nope. When more than one cell does not match, it gives the
number, for example 3 cells matched no option and kept their value. In a multi-select cell,
one part that does not match keeps the whole cell. An empty pasted cell clears a dropdown. A ref
dropdown offers at most 200 rows of the target table, so a label past that limit cannot be
matched.
A fill continues a series. Select the source cells, then drag the small square at the
corner of the selection. A whole number, an amount, and an ISO date continue a constant step.
One date steps one day. Task 1 becomes Task 2, and any zero padding is kept. A datetime,
a checkbox, a dropdown, and text without a trailing number repeat instead. A datetime does
not step, because a step across a clock change is the page time zone’s question. A fill to the
side into a different dropdown column carries the label that is shown, and matches it like a
paste: a label with no option keeps the cell’s value, and the line under the grid reports it.
Ctrl+Z undoes one step: an edit, a paste, a fill, a clear, an added row, or a removal mark. The grid keeps up to 200 steps. Ctrl+Y puts a step back.
Sorting and filtering reload the grid. A header click follows the list’s own sort link, so the server sorts every row and not only the loaded ones. The filter form reloads the list too. If the grid holds unsaved edits, it asks before it follows either one, and before the reader leaves the page.
A row that another person moves into a part of the list that the grid already loaded appears after the next reload.
Status: actions: save, actions: add, and actions: delete all work. An img column in
columns: is accepted by the compiler and refused when the tenant is opened, because there is
no editor for it yet. Edit a picture in a form on the row’s own page. The grid is tested in
Chromium and WebKit against the real engine on every change. It has not been tested with a
screen reader. See Project status for what that means and what can still
change.