Skip to content

Tables#

A table is two things: a pipeline over rows — filter, sort, paginate — and a grid of roles a screen reader can navigate. leptoskit_table is both, ported from TanStack Table at its core: typed columns and pure state on one side, a rendered grid of ARIA roles on the other.

[dependencies]
leptoskit_table = "0.1"

Or enable leptoskit's table feature and import leptoskit::table::*.

examples/observatory is a complete app built this way: a sortable, searchable star catalogue with its own page labels.

Columns#

A Column is data, not state: build the set once. The cell reads a string; sorting and filtering are defined over the row itself, so they never depend on how a cell happens to format.

use leptoskit_table::Column;

struct Planet { name: &'static str, moons: u8 }

let columns = [
    Column::new("name", "Name", |planet: &Planet| planet.name.to_owned()).sortable(),
    Column::new("moons", "Moons", |planet: &Planet| planet.moons.to_string())
        .sort_by(|a, b| a.moons.cmp(&b.moons)),
];

.sortable() sorts lexically over the cell text; .sort_by compares the rows. .filter_by replaces the column's contribution to the global filter, which by default matches cell text case-insensitively across any column.

State, owned by the screen#

TableState holds the active sort, the global filter text, and the page. It never mutates itself: reducers propose, the screen stores.

use leptoskit_table::{TableState, for_header_press, for_filter, for_page, rows};

// Twenty-five rows to a page; a page size of zero turns pagination off.
let state = TableState::new().with_page_size(25);

// A press cycles one column through none → ascending → descending → none.
let state = for_header_press(&state, "moons");

// Typing filters and restarts at the first page.
let state = for_filter(&state, text);

// Page turns clamp to the filtered rows' pages.
let total = rows(&data, &columns, &state).total;
let state = for_page(&state, total, next_page);

rows resolves the pipeline in the TanStack order — filter, then a stable sort, then pagination — and returns the page plus the counts the controls need. A sort on a column that is not sortable, or on an id no column carries, is inert: the rows keep their order. A page past the end, after the data shrank, shows the last page.

The rendered grid#

DataTable is one composition of all of it: sortable headers that carry aria-sort while their column runs the sort, cells from the columns' readers, a filter field, and a previous/page-status/next footer when the state has a page size. Every interaction hands back the whole next state, so the screen only stores it:

use leptoskit_table::{DataTable, TableLabels, TableState};

let state = RwSignal::new(TableState::new().with_page_size(25));
let labels = TableLabels::default();

move || DataTable(&columns, &planets, &state.get(), &labels, move |next| state.set(next))

TableLabels holds the words the table shows and announces — the filter field's name, the page buttons, and the page status — so a translated screen passes its own; Default is English.

The grid is table, row, columnheader, and cell roles over the shared element tree. A sortable header holds a real button, so a screen reader announces both the column and the control. Build a custom grid from rows and the same roles when this composition is not the one — everything the pipeline produces is public.

On each platform#

The table renders on the web and every native backend. Screen readers navigate it as a table on the web, macOS, Windows, and Linux. iOS and Android have no table roles, so their screen readers read the headers and cells as text in order. aria-sort reaches the web only: on native platforms the sorted header shows its ▲ or ▼, and the direction is not announced.

esc