[data-reveal]{opacity:1!important;transform:none!important}
AntelopeJS DMS
DocumentationColumns and displays

Columns and displays

How a table view lays out its columns on wide and narrow screens, expands and pins rows, and offers the kanban display.

These options shape how a TableView draws its rows: how cells wrap, which columns a narrow screen keeps, what an expanded row shows, which rows stay pinned, and the kanban board.

Wrapping complex cells

The grid clips cell content to one line by default and inherits white-space: nowrap from the table container. Set cellWrap: true on a controller's @Column to let its default cell renderer wrap without a line limit. Other columns keep their existing layout.

ts
@Column({
  name: "Access",
  type: new AccessSummaryType(),
  cellWrap: true,
})

Wrapping keeps secondary text such as scope hints visible without changing the data type, forms, or exports. Long unbroken text can break within the column. Rows grow to fit their content, a wrapped cell keeping 10px above and below its lines so they clear the row's hairlines (a line that fits keeps the row at its height); column widths and pinning do not change.

false and omission both retain the theme default. A renderer's own whitespace-nowrap, fixed widths, or flex constraints still apply: use a wrapping or vertical container for a badge plus hint, and keep only the badge non-wrapping. This option does not create a tooltip or a disclosure control. Avoid hiding essential information inside a separately clamped child.

Standalone TableColumn definitions accept the same option. A custom <column-id>-cell slot replaces the default wrapper and owns its layout. TableView does not forward these slots. Alternate displays such as kanban are unaffected.

Narrow screens

A table wider than its card, with many columns or on a phone, scrolls sideways inside the card, the header and the pinned columns (the selection, the row actions) staying in place. The side still hiding columns fades out with an inset shadow, as the scrollbar sits under the last row, out of view on a long list; scrolled to an end, its fade goes. A grid of more than three or four columns is still hard to read on a phone. A table opts in to a card per row there with mobileDisplay, naming a display it offers:

src/pages/tickets.ts ts
static tickets = TableView(ticketDataAPI, {
  labelKey: "subject",
  displays: [{ id: "table" }, { id: "cards" }],
  mobileDisplay: "cards", // phones open on the cards, wide screens on the grid
  card: { fields: ["customer", "priority", "status"] },
});

A phone then opens on the cards, and the switcher still leads to the grid. The server cannot see the viewport: it draws defaultDisplay, and the phone moves to mobileDisplay once the page is in the browser. A display the user picked, one the open view fixes with its own display, or a default display drawing its whole interface keeps its place. A view that sets no display, such as a views.defaultView showing every row or a view the URL names, opens on the phone's display, and the phone's display is never saved as the user's choice: the same user on a laptop keeps the grid. An unknown mobileDisplay throws at registration, as defaultDisplay does.

Hiding a column on one page

A data controller declares its columns once, and every TableView over it shows them. A page listing the controller's rows for one record, such as a workspace's invoices on the workspace's page, has a column that says the same thing on every row. columns.hidden opens it hidden in that table view only; the other table views over the controller keep it.

ts
static invoices = TableView(InvoiceAPI, {
  routeParamFilters: { id: { field: "workspace" } },
  columns: { hidden: ["workspace"] },
});

A hidden column behaves as one declared isVisible: false: the user can still show it from the column menu, it can still be filtered on, and the forms keep the field. A name the controller has no column for throws at registration. A view or the user's saved choice of columns applies over it.

Layout

layout decides how much a table draws around its rows. The default, "full", is the dashboard grid: caption and count, a search button, filters, sort, refresh, the ⋯ table menu (columns, density, page size, export, import), column header menus and resizing, and a footer with the page size picker. "compact" is the list of a settings page: no caption (the page header names it, and the tabs move up into the header band), an always-open search field, quick filters, custom buttons, sortable headers, the row menu and a footer with the count and the page size picker.

src/pages/members.ts ts
static members = TableView(memberAPI, {
  layout: "compact",
  searchPlaceholder: "$page.members.search",
  quickFilters: [{ field: "roleIds", label: "$page.members.role" }],
  pageSize: 25,
  footer: { countLabel: "$page.members.count", hint: "$page.members.hint" },
});

Whatever the layout, a control shows only when it means something:

ControlShown when
Captioncaption is set (without it, the tabs move up into the header).
Searchthe controller declares @Searchable fields.
Filtersa column is filterable.
Exportthe caller holds the table's export action.

A display's capabilities still apply on top (a kanban hides the tabs whatever the layout). Keyboard shortcuts, row actions, selection, presence, density and the maxHeight scroll area are unaffected.

Cells drawn by a display

display on @Column draws the grid cells another way (an avatar and a name, a status pill, a progress bar, a sparkline, a duration), while the column keeps its type for forms, filters and exports. A display is a typed class (new DefaultDisplays.StatusPillDisplay({ tones })); a module registers its own with @RegisterDisplay("<module>:<id>"). See Cell displays.

Expandable rows

expandable adds a caret column in front of the content. Opening a row shows a detail band under it, on the muted surface: either the configured fields as a label/value list, or a registered frontend component that draws the whole band in their place. The option is a union, so giving both does not compile (and a schema-built option giving both, or neither, throws at registration).

src/pages/orders.ts ts
static orders = TableView(orderDataAPI, {
  caption: "Orders",
  expandable: {
    // Column keys, or { key, label } to relabel one. Each value renders
    // through its column's data type, exactly like a grid cell.
    fields: [{ key: "shippingAddress", label: "Ship to" }, "carrier", "payment"],
    fieldsLabel: "Shipping & payment",
    defaultExpanded: "first", // "none" (default) | "first" | "all"
  },
});

static orderLines = TableView(orderLineAPI, {
  expandable: {
    // Resolved by name from the global frontend registry; draws the band.
    component: CustomComponent("OrderLinesDetail"),
    single: true, // opening a row closes the previous one
    lazyLoad: true, // read the row from `get` when it opens
  },
});
OptionTypeDefaultPurpose
fieldsArray<string | { key; label? }>NoneColumns listed in the band (not with component). A field must name a declared @Column (registration throws otherwise) and be @Listable to carry a value, unless lazyLoad reads the row.
fieldsLabelstringNoneEyebrow above the field list ($-prefixed: i18n key).
componentComponentNoneFrontend component drawing the whole band (not with fields). It receives TableViewExpandedRowProps, plus its own options: row (the listed row, or the one lazyLoad read), rowId, columns (the column metadata), labelKey, actions (the table's row actions and their per-row predicates), open (what a click on the row does) and refresh (reloads the table and the open bands).
defaultExpanded"none" | "first" | "all""none"Rows open on arrival, and again whenever a new set is listed: page, page size, filter, search, sort, tab or archive view.
singlebooleanfalseAt most one row open at a time.
lazyLoadbooleanfalseRead the row from the data API's get route when it opens, and show the band with that answer. Not for a source table, which has no get route.

A field the grid should not show stays listed and takes isVisible: false on its @Column: it is then read off the row for the band only (the column manager can still show it). Data a component needs that is no column at all, such as order lines or a timeline, only has to be @Listable on the controller.

With lazyLoad, the band reads its row from get when it opens instead, so detail data need not be listed: the list stays light and the details are fetched only for the rows someone opens. The band shows a placeholder while the row loads and, if the read fails, an error with a retry that reads that row again; the component mounts only once the row is in. What was read is kept while the rows stay listed: any reload of the list (refresh, realtime update, a new page or filter) drops it, and the open rows read it again.

A refresh of the same set (the refresh button, a realtime update) keeps the rows the user opened, minus those no longer listed. The caret is a real button (aria-expanded, and aria-controls naming the band while it is open), so it works from the keyboard. It sits next to the selection checkbox and stays pinned with it, and the band spans the presence rail, the row actions and pinned columns; density, the maxHeight scroll area, archive mode and tabs apply as usual. Alternate displays (kanban, cards, custom displays) are unaffected.

Pinned rows

pinnedRows lists first the rows a rule accepts (the same rule a row action's rule takes), whatever column the user sorts on: the base language of a languages table stays on top. The sort still orders the pinned rows among themselves, and the others after them.

src/pages/languages.ts ts
TableView.fromSource({
  fetchUrl: "/api/languages",
  columns,
  defaultSort: { field: "coverage" },
  pinnedRows: { field: "isBase", equals: true },
});

The rows pinned are those of the list the route answers: a route answering every row (a source table without paginate) has them on the first page, whichever page they would otherwise fall on; a route answering one page, such as a data controller or a paging source, has them first within that page. The grouped display keeps each group's rows together and pins nothing, and registration refuses pinnedRows beside reorder, whose rows keep the position the user gave them.

Kanban display

Setting kanban offers the built-in kanban board as an alternate display: cards grouped into columns by a field, with drag-and-drop between columns writing the group field back:

src/pages/tasks.ts ts
static board = TableView(taskDataAPI, {
  caption: "Tasks",
  labelKey: "name",
  rowActions: { add: true, edit: true, delete: true },
  kanban: {
    groupByField: "status", // default grouping; users can switch to any eligible field
  },
  card: { fields: ["priority", "assignee"] },
  defaultDisplay: "kanban",
});

KanbanOptions:

OptionTypeDefaultPurpose
groupByFieldstringrequiredThe column cards group by. Must be a non-multiple SelectType, a BooleanType, or a StatusType column. Anything else throws at registration.
draggablebooleantrueAllow dragging cards between columns to update the group field.
columnMaxHeightstring"60vh"Column height before the card list scrolls.

Kanban manages its own data (one query per column) and hides the filter tabs while active. Beyond kanban, a frontend layer can register new displays, such as a calendar, a gallery or a map, that inherit the whole toolbar: see TableView displays.