PlDataTable
A table that owns its rows. It sorts them, narrows them to what was typed, hands them out a page at a time and remembers which of them are ticked, and draws exactly the grid PlTable draws, because both draw it out of one place.
import { PlDataTable, type PlDataTableColumn } from 'plass-ui';
const columns: PlDataTableColumn<Invoice>[] = [
{ key: 'id', header: 'Invoice', sortable: true },
{ key: 'customer', header: 'Customer', sortable: true },
{ key: 'total', header: 'Total', align: 'end', sortable: true }
];
<PlDataTable
columns={columns}
rows={invoices}
getRowKey={(row) => row.id}
searchable
selection="multiple"
/>;import 'package:plass_ui/plass_ui.dart';
PlDataTable<Invoice>(
rows: invoices,
rowKey: (Invoice row, int index) => row.id,
searchable: true,
selection: PlDataTableSelection.multiple,
columns: <PlDataTableColumn<Invoice>>[
PlDataTableColumn<Invoice>(
key: 'customer',
header: const Text('Customer'),
sortable: true,
value: (Invoice row) => row.customer,
cell: (Invoice row, int index) => Text(row.customer),
),
],
);PlTable or PlDataTable
PlTable draws a grid it is given. Reach for it when the rows are already in the order they belong in. A summary, a receipt, a comparison, anything the reader is not going to interrogate. It renders on a server, which this cannot.
PlDataTable is for the table a reader works on: the one they sort, search, tick and page through. Every one of those is a decision that has to be remembered between renders, which is what makes it a client component and PlTable not one.
Below the columns they are the same grid (the measured column widths, the hover band, the rule between rows, the pinned header), because both are drawn from one internal module. A sorted table and a plain one on the same page cannot come out a shade apart.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| variantshared | 'solid' | 'glass' | 'ghost' | 'glass' | What the surface is made of: tinted glass, a clear sheet, or nothing |
| sizeshared | 'xs' | 'sm' | 'md' | 'lg' | 'xl' | 'md' | The type scale of a cell and the height of a row |
| colorshared | 'primary' | 'secondary' | 'success' | 'warning' | 'danger' | 'info' | 'primary' | Semantic colour role. Arbitrary colour values are not accepted |
| densityshared | 'default' | 'compact' | 'default' | Cell padding only — never the type scale |
| elevationshared | 0 | 1 | 2 | 3 | 0 | Drop shadow depth. 0 means no shadow at all |
| columns * | readonly PlDataTableColumn<Row>[] | — | The columns, in the order they appear |
| rows * | readonly Row[] | — | The rows, in the order they arrived in |
| getRowKey | (row: Row, index: number) => Key | — | A stable key per row, and the one prop worth setting before any other: the index it defaults to stays behind when a sort moves the row |
| caption | ReactNode | — | Shown above the grid, and read out as the table's accessible name |
| empty | ReactNode | labels.empty | What to show instead of rows when there are none left to show |
| striped | boolean | false | Tints every other row |
| hoverable | boolean | false | Lights the row under the pointer |
| stickyHeader | boolean | true | Pins the column names while the rows scroll under them. A table that sorts is a table a reader scrolls |
| maxHeight | number | string | — | A hard cap on the grid. Past it the rows scroll inside the sheet |
| onRowClick | (row: Row, index: number) => void | — | Makes rows activatable. Also turns on the hover treatment |
| sort | PlDataTableSort | null | — | The sorted column and its direction. Pass it to control the sort |
| defaultSort | PlDataTableSort | null | null | Where the sort starts when the table keeps it itself |
| onSortChange | (sort: PlDataTableSort | null) => void | — | Called with the sort a heading press asks for, null for the third press |
| searchable | boolean | false | Draws a field above the grid that narrows the rows to what was typed |
| search | string | — | The query. Pass it to control the field |
| defaultSearch | string | '' | Where the query starts when the table keeps it itself |
| onSearchChange | (search: string) => void | — | Called with what the reader typed |
| searchPlaceholder | string | labels.search | The field's placeholder |
| selection | 'none' | 'single' | 'multiple' | 'none' | How many rows may be ticked at once |
| selected | readonly Key[] | — | The ticked rows, as their keys. Pass it to control the selection |
| defaultSelected | readonly Key[] | [] | Where the selection starts when the table keeps it itself |
| onSelectedChange | (selected: Key[], rows: Row[]) => void | — | Called with the keys of every ticked row, and with the rows themselves |
| isRowSelectable | (row: Row, index: number) => boolean | — | Keeps a row out of the selection — a total line, a row already spent |
| paging | 'scroll' | 'pages' | 'scroll' | How the rows are handed out |
| pageSize | number | 10 | How many rows a page holds |
| page | number | — | The page being read, counted from 1. Pass it to control the pager |
| defaultPage | number | 1 | Where the pager starts when the table keeps it itself |
| onPageChange | (page: number) => void | — | Called with the page a pager press asks for |
| rowCount | number | — | How many rows there are in total when the table is only handed one page. Required for manual paging and ignored without it |
| manual | readonly ('sort' | 'search' | 'pages')[] | — | The stages an application has already done to rows itself |
| loading | boolean | false | Draws bars in place of the rows and marks the grid busy |
| toolbar | ReactNode | — | Drawn in the toolbar, at the end. A filter, a button, a count of its own |
| footer | ReactNode | — | Drawn in the footer, at the start, in place of the row count |
| Prop | Type | Default | Description |
|---|---|---|---|
| columns * | List<PlDataTableColumn<T>> | — | The columns, in the order they appear |
| rows * | List<T> | — | The rows, in the order they arrived in |
| rowKey | Object Function(T row, int index)? | — | A stable key per row, and the one thing worth setting before anything else. Left out, a row is identified by its position, which stays behind when a sort moves the row |
| caption | Widget? | — | Drawn above the grid, inside the sheet |
| empty | Widget? | Text(labels.empty) | What to show instead of rows when there are none left to show |
| striped | bool | false | Tints every other row |
| hoverable | bool | false | Lights the row under the pointer |
| stickyHeader | bool | true | Pins the column names while the rows scroll under them. A table that sorts is a table a reader scrolls |
| maxHeight | double? | — | A hard cap on the grid, in logical pixels. Past it the rows scroll inside the sheet |
| onRowPressed | void Function(T row, int index)? | — | Makes rows activatable. Also turns on the hover treatment |
| sort | PlDataTableSort? | — | The sorted column and its direction. Pass it to control the sort |
| initialSort | PlDataTableSort? | null | Where the sort starts when the table keeps it itself |
| onSortChanged | ValueChanged<PlDataTableSort?>? | — | Called with the sort a heading press asks for, null for the third press |
| searchable | bool | false | Draws a field above the grid that narrows the rows to what was typed |
| search | String? | — | The query. Pass it to control the field |
| initialSearch | String | '' | Where the query starts when the table keeps it itself |
| onSearchChanged | ValueChanged<String>? | — | Called with what the reader typed |
| searchPlaceholder | String? | labels.search | The field's placeholder |
| selection | PlDataTableSelection | PlDataTableSelection.none | How many rows may be ticked at once |
| selected | List<Object>? | — | The ticked rows, as their keys. Pass it to control the selection |
| initialSelected | List<Object>? | [] | Where the selection starts when the table keeps it itself |
| onSelectedChanged | void Function(List<Object> selected, List<T> rows)? | — | Called with the keys of every ticked row, and with the rows themselves |
| isRowSelectable | bool Function(T row, int index)? | — | Keeps a row out of the selection — a total line, a row already spent |
| paging | PlDataTablePaging | PlDataTablePaging.scroll | How the rows are handed out |
| pageSize | int | 10 | How many rows a page holds |
| page | int? | — | The page being read, counted from 1. Pass it to control the pager |
| initialPage | int | 1 | Where the pager starts when the table keeps it itself |
| onPageChanged | ValueChanged<int>? | — | Called with the page a pager press asks for |
| rowCount | int? | — | How many rows there are in total when the table is only handed one page. Required for manual paging and ignored without it |
| manual | List<PlDataTableStage> | [] | The stages an application has already done to rows itself |
| loading | bool | false | Draws bars in place of the rows |
| toolbar | Widget? | — | Drawn in the toolbar, at the end. A filter, a button, a count of its own |
| footer | Widget? | — | Drawn in the footer, at the start, in place of the row count |
| variantshared | PlassVariant | PlassVariant.glass | What the surface is made of: tinted glass, a clear sheet, or nothing |
| sizeshared | PlassSize | PlassSize.md | The type scale of a cell and the height of a row |
| colorshared | PlassColor | PlassColor.primary | Semantic colour role. It reaches the hover tint, the selection tint, the ticks and the focus ring and nothing else: data arrives with its own colours |
| densityshared | PlassDensity | PlassDensity.standard | Cell padding only — never the type scale |
| elevationshared | int | 0 | Drop shadow depth. 0 means no shadow at all |
| semanticLabel | String? | — | The name a screen reader gives the table |
| selectAllLabel | String? | labels.selectAll | Names the box at the top of the tick column |
| selectRowLabel | String? | labels.selectRow | Names a row's own tick |
| searchLabel | String? | labels.search | Names the search field |
Every native <div> attribute passes straight through to the sheet. color and onSelect are excluded because they collide with the props above.
Generic in Row and therefore not a forwardRef, for PlTable's reason: a component wrapped in React.forwardRef loses its type parameter, and the row type is the whole point of the API.
Generic in its row's type, PlDataTable<Invoice>, so a column is handed the row, typed, and hands back a widget.
The uncontrolled starting values are named initialSort, initialSearch, initialSelected and initialPage rather than default…, which is Flutter's own convention and the one every other widget in this package follows.
PlDataTableColumn
| Prop | Type | Default | Description |
|---|---|---|---|
| key * | string | — | Identifies the column, and — unless value or render says otherwise — names the property to read off each row |
| header | ReactNode | — | The heading. Defaults to the key, which is usually not what you want |
| width | number | string | — | The default width. A number is pixels, a string is any CSS length |
| alignshared | 'start' | 'center' | 'end' | 'start' | Text alignment. Numbers usually want end so their digits line up |
| render | (row: Row, index: number) => ReactNode | — | Renders the cell. Without it the cell is row[key] rendered as-is |
| value | (row: Row) => unknown | — | What the sort and the search see. Defaults to row[key], and is needed whenever the cell is drawn rather than printed |
| sortable | boolean | false | Puts the heading in the sort rotation: ascending, descending, then back to the order the rows arrived in |
| compare | (a: Row, b: Row) => number | — | Orders two rows against each other. The direction is applied to whatever it says |
| unsearchable | boolean | false | Keeps this column out of the search |
| Prop | Type | Default | Description |
|---|---|---|---|
| key * | String | — | Identifies the column, and — unless value or render says otherwise — names the property to read off each row |
| cell * | Widget Function(T row, int index) | — | Builds the cell for a row. Required, because Dart has no row[key] on an arbitrary type |
| header | Widget? | — | The heading. Defaults to the key, which is usually not what you want |
| width | double? | — | A fixed width in logical pixels. Left out, the column is as wide as its content and then takes a flex share of what is left |
| flex | double | 1 | How much of the leftover width this column takes. Ignored when width is set |
| alignshared | PlassAlign | PlassAlign.start | Text alignment. Numbers usually want end so their digits line up |
| value | Object? Function(T row)? | — | What the sort and the search see. A sortable or searchable column needs it: cell returns a widget, and a widget has no order and no text to look inside |
| sortable | bool | false | Puts the heading in the sort rotation: ascending, descending, then back to the order the rows arrived in |
| compare | int Function(T a, T b)? | — | Orders two rows against each other. The direction is applied to whatever it says |
| unsearchable | bool | false | Keeps this column out of the search |
cell is required for PlTableColumn's reason: Dart has no row[key] on an arbitrary type. value is the other half of the same problem. The sort and the search cannot read a widget, so a sortable or searchable column says what it holds.
What the shared axes (variant size color density elevation) mean across the library is in prop conventions.
Controlled, or not
Sort, search, selection and page are each uncontrolled by default and controllable one at a time. Pass the value and the table draws what it is told; leave it out and the table keeps it.
Those four together are what make one component cover both of the tables people build. The ordinary table is the markup at the top of this page. A table backed by a server is the same markup with manual and the handlers, and nothing in between changes shape.
Examples
Sorting
A sortable column's heading becomes a control: ascending, descending, then back to the order the rows arrived in. That third press is the part most tables leave out, and it matters. The arrival order is usually the order the server chose, and a table that can never be put back has thrown it away.
The mark is drawn faintly on every sortable heading rather than appearing on hover. A heading that only looks pressable once the pointer is on it is a heading nobody presses.
Values are compared as what they are: numbers numerically, dates by the moment they name, and nothing sorts last in both directions. A column of amounts with three blanks in it is a column whose blanks are not the smallest amounts, and a reader who reversed the sort to find the largest should not be handed the empty ones instead.
Text is compared with localeCompare, so apple comes before Banana and Ösi before Zoe. Sorting by code point puts every capitalised word above every lower-case one, which is a list a reader cannot scan.
Text is compared case-insensitively, so apple comes before Banana. Accents are not folded, and the reason is the one PlTransfer's search gives: Dart's core has no String.normalize and this package has no dependencies, so an Ö sorts where its code point puts it. The React build uses localeCompare and gets it right.
compare takes over for a column whose order is its own. The direction is applied to whatever it returns, so a caller's comparator reverses the way the built-in one does rather than having to know which way round it is being asked.
value, and why a drawn cell needs one
render decides what a reader sees. value decides what the sort and the search see. Most columns need neither. The cell is the property and that is what is compared and matched.
The moment a cell is drawn rather than printed, the two come apart. A status column showing a chip has no text to search and no order to sort; a total column printing $1,240.00 sorts as a string, which puts $89 after $1,240. value is where the column says what it actually holds.
Every sortable or searchable column needs value here, not only a drawn one: cell returns a widget in all cases.
Selection
single keeps one row; multiple adds a tick-everything box at the top of the column. Both hand back the keys and the rows. Out of every row the table has, not only the page on screen, so a selection that survived paging hands back what it survived on.
The header box goes indeterminate when part of the page is chosen. A half-filled page under a plain unticked box reads as "nothing here is chosen", which is the opposite of what is true.
Shift extends the selection from the last row pressed to this one, in the order the rows are currently in, which is what a reader dragging down a sorted page means by "these". isRowSelectable keeps a row out of the selection and out of the tick-everything with it.
A chosen row carries aria-selected as well as the tint. A row that is visibly tinted and silently unselected is a row a screen reader disagrees with the screen about.
A press on the tick is a press on the tick: it does not also activate the row, so selection and onRowClick can be on the same table.
The row's tick carries the state a screen reader reads; the tint is what a sighted reader sees. Flutter's Table has no row-level selected flag to set, and a Semantics(selected: true) on each cell would announce it once per column.
Shift is read off the hardware keyboard, so the range gesture is there on a desktop or the web and simply does not arise on a touch device.
Search
searchable draws a field above the grid, inside the sheet. It matches against every column's value, folded once per keystroke rather than once per row per column, the same matcher PlTransfer and PlCommandPalette use, so a reader who has learned what the filter does in one part of a product has learned it for the rest.
unsearchable takes a column out of it. Right for a column of identifiers nobody types, where a match is a row the reader cannot see the reason for.
Case and accents are both folded, so jose finds José.
Case is folded; accents are not, for the reason given under sorting.
Paging
scroll is the default and hands out every row. Pair it with maxHeight and they scroll inside the sheet.
pages cuts a slice and puts a PlPagination in the footer, with the count beside it. Right when a row's position in the whole set is information (a ledger, an audit log), and the only honest option when the rows are fetched a page at a time.
Sorting or searching sends the reader back to the first page. Page nine of a different set of rows is not where they were.
manual: when the server does the work
Naming a stage in manual means the rows arriving have already had it done to them. The table reports what the reader asked for and draws what it is handed, rather than sorting an already-sorted page or filtering a page that is one tenth of the data.
rowCount goes with manual paging, and it has to: a table holding ten rows out of ninety has no way to know that the pager should offer nine pages.
loading
Bars in place of the rows, as many as a page holds, so the grid does not change height when the data arrives. A table that grows under the pointer is a table where the row somebody was about to press moves out from under them.
The grid carries aria-busy while it waits.
toolbar and footer
Two slots inside the sheet, above the grid and below it, on the same rules the caption sits on. toolbar is where a filter or a bulk action goes, beside the search field. footer replaces the row count at the start of the pager's row.
They are inside the sheet rather than floating above and below it because they belong to the table: a "Delete 3" button that is not visibly part of the grid it is going to act on is a button whose scope the reader has to guess.
Out of scope
It does not virtualize. A hundred thousand rows in one DOM is a slow page whichever component draws it, and the honest answer is paging="pages", which is also the only shape that works when the rows are being fetched. A virtualized body is a different component with a different bargain (fixed row heights, a scrollbar whose length no longer matches the data), and hiding that inside this one would make every table pay for it.
It does not resize or reorder columns by dragging. Both are real features and both belong to a table an application has built on this one: they need somewhere to persist what the reader dragged, and a component that forgets the widths on every mount has given the reader a toy.
It does not export. Turning rows into a file is the application's data and the application's filename, and it is three lines beside the table rather than a prop on it. toolbar is where the button goes.
It sorts on one column. A sort three keys deep is a query, and a reader looking at the table cannot see the third key or work out why two rows are in the order they are in. An application that genuinely needs one owns the sort with manual.
Accessibility
- Renders a real
<table>with<thead>,<tbody>,<th scope="col">and<td>. The same markupPlTablerenders, and the same reasons behind every inline style on it. - A sortable column announces its direction with
aria-sorton the heading, not on the button inside it: the heading is what a screen reader reads when it enters a cell in that column, and a state on the button would only be heard by a reader who happened to land on the button. - The sort control is a bare
<button>wearing the heading's own type. APlButtonhere would be a control on a control, bringing a background, a radius and a height into a cell whose job is to sit flush against the rule under it. - Its focus ring is inset, because the sheet clips at its rounded corner and a ring outside the first heading would have its top sliced off.
- A chosen row carries
aria-selected; each tick is named by the locale'sselectRowand the header's byselectAll. - The grid carries
aria-busywhileloading. captionis drawn above the sheet and markedaria-hidden, with a real<caption>inside the table carrying the same words. A caption that scrolled away would take the table's accessible name with it.
- The grid is a real
Table, announced as a table with rows and cells, and every heading is announced as the column's header. - A sorted heading says its direction out loud, as its semantics value. That is the one place the two builds differ in kind rather than in spelling:
aria-sortis a platform affordance every screen reader speaks in the reader's own language, and Flutter's semantics have no equivalent, so the word has to be said, and a word that is said has to be translated.sortedAscendingandsortedDescendingare in the label set here and are not in the React one. - The tick in a row carries whether the row is chosen; the tint is what a sighted reader sees.
- The pinned header band is silent, because the row it copies is not.
- The row's focus stop is in its first cell and the ring is painted by the row, inset, for the sheet's rounded corner.
Notes
getRowKeybefore anything else. Everything the table remembers is remembered by key: the selection, the range anchor, the identity React and Flutter reconcile rows by. Defaulting to the index is right for a static table and wrong for this one. Sorting moves a row and its index stays behind.onSelectedChangehands back rows from every page, not from the page on screen. A selection made across three pages is three pages of rows.- The search, the sort and the page are computed in that order, so a page is a page of the narrowed, ordered set rather than a page of the raw rows with a filter applied afterwards.