DataPage
DataPage turns a generated Arc query into a list page: an action toolbar, a paged table with sorting and filtering, row selection, and an optional details pane for the selected row.
Purpose
Section titled “Purpose”DataPage combines a menu bar, data table, and optional detail view into a single component for building data-driven pages. You declare the pieces as children and props; DataPage wires selection between them.
Key Features
Section titled “Key Features”- Integrated data table with filtering, sorting, and pagination of the loaded page, or of the complete result on the server with
serverQuery - Action toolbar whose items can be disabled until a row is selected
- Support for both snapshot queries and observable queries
- Selection management, uncontrolled or controlled
- Split view with a resizable details panel
- A layout that keeps the paginator inside the height the page is given
Prerequisites
Section titled “Prerequisites”- Your app renders
<Arc>from@cratis/arc.reactaroundCratisComponentsProvider. DataPage runs the query through Arc’s React hooks and does not replace that provider. - A generated Arc query proxy (
IQueryFororIObservableQueryFor) for the rows. - The Components stylesheet (
@cratis/components/styles, or@cratis/components/DataPage/styles) imported once at your app entry point. See Layout. - An ancestor element with a definite height. See DataPage needs an ancestor with a height.
Basic Usage
Section titled “Basic Usage”Declare the columns and menu actions with the compound DataPage.Columns and DataPage.MenuItems children. DataPage, MenuItem, and Column all come from @cratis/components/DataPage:
import { useState } from 'react';import { DataPage, MenuItem, Column } from '@cratis/components/DataPage';import { FaPlus, FaPencil, FaTrash } from 'react-icons/fa6';import { AllAuthors, type Author } from './Author'; // generated Arc query proxy and read model
export function Authors() { const [selected, setSelected] = useState<Author | null>(null);
const addAuthor = () => { // open your "register author" dialog here }; const editAuthor = () => { if (!selected) return; // open your edit dialog for `selected` here }; const removeAuthor = () => { if (!selected) return; // ask for confirmation, then execute your remove command for `selected.id` };
return ( <div style={{ height: '100vh' }}> <DataPage<AllAuthors, Author, object> title='Authors' query={AllAuthors} emptyMessage='No authors found' dataKey='id' onSelectionChange={(event) => setSelected(event.value)} > <DataPage.MenuItems> <MenuItem label='Add' icon={FaPlus} command={addAuthor} /> <MenuItem label='Edit' icon={FaPencil} disableOnUnselected command={editAuthor} /> <MenuItem label='Remove' icon={FaTrash} disableOnUnselected command={removeAuthor} /> </DataPage.MenuItems> <DataPage.Columns> <Column field='name' header='Name' sortable /> <Column field='id' header='Id' /> </DataPage.Columns> </DataPage> </div> );}The page renders a toolbar with the three actions above a table with a sortable Name column. Edit and Remove stay disabled until a row is selected.
A few things this example relies on:
MenuItem.commandreceives no arguments. To act on the selected row, keep it in your own state fromonSelectionChange, as above. DataPage still manages the selection itself;onSelectionChangeonly reports it to you.- The explicit type arguments
<AllAuthors, Author, object>tell TypeScript the row type. Without them, and without a typeddetailsComponent, TypeScript infers the row asobject, soevent.value.namedoes not compile. The third argument is the query’s parameter type; useobjectfor a query without parameters. titleis required, but DataPage does not render it as a visible heading. Render your own heading or breadcrumb outside the page if you need one.
MenuItem is also available as DataPage.MenuItem. See Menu Items for the full item surface.
List-and-detail with a details panel
Section titled “List-and-detail with a details panel”Pass a detailsComponent and DataPage adds a resizable split: the table on the left, your component on the right, shown only while a row is selected. Type the component with IDetailsComponentProps<T>, exported from @cratis/components/DataPage; it receives the selected row as item:
import { DataPage, Column, type IDetailsComponentProps } from '@cratis/components/DataPage';import { AllAuthors, type Author } from './Author';
const AuthorDetails = ({ item }: IDetailsComponentProps<Author>) => ( <section aria-label={`Details for ${item.name}`} style={{ padding: '1rem' }}> <h2>{item.name}</h2> </section>);
export function Authors() { return ( <div style={{ height: '100vh' }}> <DataPage title='Authors' query={AllAuthors} emptyMessage='No authors yet' dataKey='id' detailsComponent={AuthorDetails} > <DataPage.Columns> <Column field='name' header='Name' sortable /> </DataPage.Columns> </DataPage> </div> );}A typed detailsComponent is enough for TypeScript to infer the row type, so this example needs no explicit type arguments. See Details Panel for loading related data, sizing, and narrow screens.
Selection
Section titled “Selection”The table uses single-row selection by default. A user selects a row by clicking it, or by focusing it and pressing Enter or Space.
- Uncontrolled (default): leave
selectionout. DataPage keeps the selected row in its own state and still callsonSelectionChangeon every change. - Controlled: pass
selectionand update it fromonSelectionChange. Passnullto clear the selection, which also closes the details pane. Passingundefinedswitches back to uncontrolled mode.
The table itself never clears a selection: clicking the selected row again selects the same row. If users need to deselect, for example to close the details pane, use controlled mode and offer an action that sets selection to null.
Provide dataKey (for example 'id') whenever rows can be replaced by new objects, which happens on every observable-query update and every page change. Without it, the table matches the selected row by object identity and loses the highlight when the row objects change.
Multiple selection
Section titled “Multiple selection”Set selectionMode='multiple' and add a <Column selectionMode='multiple' /> inside DataPage.Columns for actions that operate on a set of rows. The column renders a checkbox per row and a select-all checkbox in its header; clicking a row also toggles it. Read the set from onSelectedItemsChange, or control it with selectedItems. disableOnUnselected menu items are disabled while the set is empty.
<DataPage<AllAuthors, Author, object> title='Authors' query={AllAuthors} dataKey='id' emptyMessage='No authors yet' selectionMode='multiple' selectedItems={selected} onSelectedItemsChange={setSelected}> <DataPage.MenuItems> <MenuItem label='Remove selected' icon={FaTrash} disableOnUnselected command={removeSelected} /> </DataPage.MenuItems> <DataPage.Columns> <Column selectionMode='multiple' headerStyle={{ width: '3rem' }} /> <Column field='name' header='Name' sortable /> </DataPage.Columns></DataPage>This is an excerpt: selected/setSelected are useState<Author[]> state and removeSelected is your handler. onSelectionChange is not called in multiple mode, so the details pane, which follows the single selection, stays closed. Select-all acts only on the rows visible after search and filtering. See Column Configuration: Selection.
Loading, empty, and failed queries
Section titled “Loading, empty, and failed queries”DataPage renders its table through DataTableForQuery or DataTableForObservableQuery, so it has the same states:
| Situation | What renders |
|---|---|
| First result still loading without rows | A loading row with loadingMessage (default Loading…) |
| Refetching with rows | Existing rows; the table is marked busy |
| Query returned no rows | emptyMessage |
| A search or column filter is active and no rows are shown | filteredEmptyMessage (default No rows on this page match the search., or No rows match the search. with serverQuery) |
| Query failed, settled without success, or was unauthorized | An alert row with failureMessage or unauthorizedMessage |
| Snapshot query missing a required argument | emptyMessage (no request is sent) |
| Observable query missing a required argument | A loading row with loadingMessage remains until every required argument has a value; Arc does not start a subscription |
Unauthorized takes precedence over failure, which takes precedence over loading. A snapshot response that is not valid JSON, such as an HTML error page from a proxy, is reported by Arc as unsuccessful without throwing and shows the failure row, not emptyMessage. Server exception text is not shown. Set the three message props on DataPage or configure the provider’s messages.dataTable defaults. See Loading, empty, and failed queries for the exact table behavior.
Required Props
Section titled “Required Props”title: Page title. Required, but not rendered visibly.query: Constructor of the generated query (IQueryFororIObservableQueryFor)emptyMessage: Message to display when no rows are availablefilteredEmptyMessage: Message shown instead ofemptyMessagewhen a search or column filter is active and no rows are shown. Defaults to the provider’smessages.dataTable.filteredEmpty(No rows on this page match the search., because only the loaded page is searched) or, withserverQuery,messages.dataTable.filteredEmptyServer(No rows match the search.)onGlobalFilterChange: Called with the search text each time it changes, so the page can tell a search is activechildren:<DataPage.MenuItems>and<DataPage.Columns>
Optional Props
Section titled “Optional Props”queryArguments: Arguments to pass to the query. An observable query resubscribes when any argument changes; a snapshot query re-runs when a required argument changes.loadingMessage,failureMessage,unauthorizedMessage: Optional React content for the query states, passed to the bound tabledataKey: Row property used as stable identity for selectionselection: Currently selected row. See Selection.onSelectionChange: Called with{ value, originalEvent }when the selection changesselectionMode:'single'(default) or'multiple'; see Multiple selectionselectedItems: Currently selected rows in multiple mode (controlled)onSelectedItemsChange: Called with the full selected set when a multiple selection changesdetailsComponent: Component rendered in the resizable details pane while a row is selectedonRefresh: Callback that DataPage passes to thedetailsComponent. DataPage does not re-run the query when it is called.globalFilterFields: Row fields searched by a search box above the table. The box appears only when this is set.globalSearchPlaceholder: Placeholder for the search inputglobalSearchAriaLabel: Accessible name for the search input; localize independently from the placeholderdefaultFilters: Initial filter state, aDataTableFilterMeta(a{ value, matchMode }constraint per field)serverQuery: Opts the table into server mode, where the server sorts, filters, and searches the complete result before paging. See Filtering scope.clientFiltering: Deprecated compatibility prop; accepted but ignored. WithoutserverQuery, filtering is always scoped to the loaded query pagetablePt/tableClassName: Stable table-part attributes and root classpaginatorPt/paginatorClassName: Stable paginator-part attributes and root classpaginatorAriaLabels: Localized labels for the paginator navigation and controlsmenubarPt/menubarClassName: Stable button-part attributes and root class for the action toolbaractionsAriaLabel: Accessible name of the action toolbar. Defaults to'Actions'.
tablePtOptions, tableUnstyled, paginatorPtOptions, menubarPtOptions, and menubarUnstyled are deprecated and have no effect.
The query-backed table inside DataPage shows a loading row instead of emptyMessage while its first result is still performing without rows.
Query Types
Section titled “Query Types”DataPage supports two types of queries:
- IQueryFor: Snapshot queries, fetched when the page mounts and again when a required argument or the page changes
- IObservableQueryFor: Observable queries that update the table when the read model changes on the server
DataPage checks whether the query class derives from QueryFor and renders the matching data table. Both tables request 20 rows per page; the page size is not configurable through DataPage.
Filtering scope
Section titled “Filtering scope”By default, column and global filters run against the currently loaded query page. Pagination continues to use the server-reported total so filtering one page never hides later pages. Sorting is also applied to the loaded page only.
To sort, filter, and search the complete result, pass serverQuery. DataPage forwards it to its table, which sends the sort as Arc sorting and the filters and search text as the query arguments serverQuery.toArguments builds; the server applies them before paging and returns the filtered rows and filtered total. See Server mode for the contract and an example.
clientFiltering remains in the public props only so existing applications continue to compile. It is deprecated, has no effect, and should not be used in new code. See DataTableForQuery filtering scope for why the browser cannot filter the complete result on its own.
Layout
Section titled “Layout”DataPage uses Allotment for the resizable split when a detailsComponent is provided. The layout consists of:
- Action toolbar (when
DataPage.MenuItemsis present) - Data table with its paginator
- Optional details panel on the right, while a row is selected
Allotment positions its panes from a stylesheet rather than from inline styles, so the split view only works once that stylesheet is on the page. From 3.0 it is vendored into @cratis/components/styles, so importing that one file at your app entry point is all it takes — you do not need allotment/dist/style.css yourself, and if you were importing it you can drop it. Without @cratis/components/styles the details pane grows to its content and clips the paginator. If you import per-area stylesheets instead of the aggregate, @cratis/components/DataPage/styles carries those vendored Allotment rules. When no detailsComponent is supplied there is nothing to split, so no split view is mounted at all.
Inside the page, the menu bar and the data table share one vertical column. The menu bar keeps the height it needs; the table region takes everything that is left and scrolls its rows internally. Given an ancestor with a real height — the condition described next — the paginator therefore sits at the bottom of the page rather than below its edge, however many rows the query returns, and whether or not the page is split.
The split does not adapt to narrow viewports: the details pane opens beside the table at every width. See Narrow screens.
DataPage needs an ancestor with a height
Section titled “DataPage needs an ancestor with a height”That division only works if there is a height to divide. Every element from the page root down is sized as a percentage of its parent, so some ancestor of DataPage has to have a definite height — a viewport unit, a pixel height, a grid row, or a flex child that is allowed to shrink. Give it one and the paginator stays on screen no matter how many rows the query returns.
// ✅ the layout gives the page a height to divide<div style={{ height: '100vh' }}> <DataPage title='Authors' query={AllAuthors} emptyMessage='No authors found'> <DataPage.Columns> <Column field='name' header='Name' sortable /> </DataPage.Columns> </DataPage></div>// ❌ nothing above resolves to a height, so the table grows to its content and// the paginator ends up past the bottom of the page<div> <DataPage title='Authors' query={AllAuthors} emptyMessage='No authors found'> <DataPage.Columns> <Column field='name' header='Name' sortable /> </DataPage.Columns> </DataPage></div>A flex or grid child counts as bounded only when it is allowed to shrink — min-height: 0 on the item, or overflow: hidden on the container. Without that, the item’s automatic minimum keeps it at content height, which is the same as having no bound at all.
When no ancestor supplies a height, DataPage falls back to a minimum height of 20rem so the page stays usable instead of collapsing to nothing. Treat that fallback as a symptom, not a solution — fix the ancestor.
Accessibility and keyboard
Section titled “Accessibility and keyboard”- DataPage renders its content inside a
<main>element. If your application shell already has a<main>, the page adds a second main landmark; account for that in your shell. - The action bar is a toolbar of buttons named by
actionsAriaLabel(default'Actions'). Disabled items are real disabled buttons. Left/Right and Home/End move between actions, and Tab still reaches each one. - Each data row is a tab stop. Enter or Space selects the focused row; there is no arrow-key navigation between rows. Selected rows carry
aria-selected="true". - Sortable headers are buttons, and the sorted column carries
aria-sort. - The table has no accessible name by default, and
titleis not used for one. Name it through the table part:tablePt={{ table: { 'aria-label': 'Authors' } }}. - The divider between the table and the details pane can be dragged with a pointer only; it is not keyboard-operable.
Troubleshooting
Section titled “Troubleshooting”| Symptom | Likely cause |
|---|---|
The paginator is cut off or the page is only about 20rem tall | No ancestor with a definite height. See DataPage needs an ancestor with a height. |
| The details pane overlaps or grows to its content | The Components stylesheet is not imported. |
The table shows emptyMessage instead of rows | The query completed empty or a snapshot query is missing a required queryArguments value. Failures, unsuccessful results, and authorization denials have separate alert rows. |
| Sorting or filtering changes only the visible page | Without serverQuery, the table sorts and filters the loaded page. Set serverQuery and have the query apply the sort and filter arguments before paging. |
| The table remains in its loading state | The first result has not arrived, or an observable query is missing required queryArguments; Arc does not subscribe until all required arguments have values. |
event.value.name or item.name does not compile | The row type was inferred as object. Add type arguments (<DataPage<AllAuthors, Author, object>) or type the detailsComponent with IDetailsComponentProps<Author>. |
| The selection highlight disappears after an update | Set dataKey so rows are matched by identity instead of object reference. |
Integration
Section titled “Integration”DataPage integrates with:
@cratis/arc/queriesand@cratis/arc.react/queriesfor data fetchingDataTableForQueryandDataTableForObservableQuerycomponents- the semantic Cratis data table plus a Cratis action toolbar of Buttons
- Allotment for split view layout — its stylesheet is vendored into
@cratis/components/styles(and into@cratis/components/DataPage/styles), so the split view lays out as long as you import one of them
See Also
Section titled “See Also”- Menu Items - Configuring the action toolbar
- Details Panel - Working with detail views
- Column Configuration - Column props, sorting, and filters
- Pass-through cheat sheet - Styling the table, paginator, and toolbar parts