Skip to content

Sortable Tables

A table is a column of rows whose cells share their borders, which border_collapse draws once between neighbors, as CSS's border-collapse: collapse does. To sort it by the header clicked, keep which column it's sorted by, and which way, in state, and sort the rows by it each render:

@dataclass(frozen=True, slots=True)
class Peak:
    name: str
    height: int
    first_ascent: int


PEAKS = (
    Peak("Everest", 8849, 1953),
    Peak("K2", 8611, 1954),
    Peak("Kangchenjunga", 8586, 1955),
    Peak("Lhotse", 8516, 1956),
    Peak("Makalu", 8485, 1955),
    Peak("Cho Oyu", 8188, 1954),
    Peak("Annapurna I", 8091, 1950),
)


@dataclass(frozen=True, slots=True)
class Column:
    label: str
    width: int
    value: Callable[[Peak], str | int]


COLUMNS = (
    Column("Peak", 15, lambda peak: peak.name),
    Column("Height (m)", 12, lambda peak: peak.height),
    Column("First ascent", 14, lambda peak: peak.first_ascent),
)


@dataclass(frozen=True, slots=True)
class Sort:
    column: int
    is_descending: bool

    def by(self, column: int) -> Sort:
        """Sorted by `column`, and the other way if it's already sorted by it."""
        return Sort(column, not self.is_descending if column == self.column else False)


CELL = content_box | border | pad_x(1)


@component
def peak_table() -> Div:
    sort, set_sort = use_state(Sort(column=1, is_descending=True))

    def sort_by(column: int) -> None:
        set_sort(lambda sort: sort.by(column))

    arrow = "↓" if sort.is_descending else "↑"
    header = Div(
        style=row | border_collapse,
        children=tuple(
            Text(
                content=f"{column.label} {arrow if index == sort.column else ' '}",
                style=CELL | width(column.width) | text_color("amber", 300) | text_bold,
                on_input=on_click(partial(sort_by, index)),
            )
            for index, column in enumerate(COLUMNS)
        ),
    )
    peaks = sorted(PEAKS, key=COLUMNS[sort.column].value, reverse=sort.is_descending)
    rows = tuple(
        Div(
            style=row | border_collapse,
            children=tuple(
                Text(content=str(column.value(peak)), style=CELL | width(column.width) | text_color("slate", 300))
                for column in COLUMNS
            ),
        )
        for peak in peaks
    )
    return Div(style=col | border_collapse, children=(header, *rows))

The peaks sorted by the year of their first ascent

┌─────────────────┬──────────────┬────────────────┐ 
│ Peak            │ Height (m)   │ First ascent ↑ │ 
├─────────────────┼──────────────┼────────────────┤ 
│ Annapurna I     │ 8091         │ 1950           │ 
├─────────────────┼──────────────┼────────────────┤ 
│ Everest         │ 8849         │ 1953           │ 
├─────────────────┼──────────────┼────────────────┤ 
│ K2              │ 8611         │ 1954           │ 
├─────────────────┼──────────────┼────────────────┤ 
│ Cho Oyu         │ 8188         │ 1954           │ 
├─────────────────┼──────────────┼────────────────┤ 
│ Kangchenjunga   │ 8586         │ 1955           │ 
├─────────────────┼──────────────┼────────────────┤ 
│ Makalu          │ 8485         │ 1955           │ 
├─────────────────┼──────────────┼────────────────┤ 
│ Lhotse          │ 8516         │ 1956           │ 
└─────────────────┴──────────────┴────────────────┘ 

Clicking a header sorts by its column, and clicking it again sorts the other way:

Each cell has the width of its column, so the cells of a column line up from row to row. Sort.by is a function of the latest sort, passed to the setter, so two clicks that arrive together sort by the second one the way the first one left it.