brumate/ui

Table

Rows and columns of data, which the user can move through with the keys, select and sort.

NameKindSize (kB)
Beach.jpgImage2400
Budget.xlsxSheet48
Notes.txtText3
Song.mp3Audio5100

Selected: none.

<script>
  import { $bind, $memo, $signal } from 'brumate/signals';
  import Cell from '@brumate/ui/Cell.brm';
  import Checkbox from '@brumate/ui/Checkbox.brm';
  import Column from '@brumate/ui/Column.brm';
  import Row from '@brumate/ui/Row.brm';
  import Table from '@brumate/ui/Table.brm';
  import TableBody from '@brumate/ui/TableBody.brm';
  import TableHeader from '@brumate/ui/TableHeader.brm';
  import type { Key } from '@brumate/ui/collection.ts';
  import type { SortDescriptor } from '@brumate/ui/table.ts';
  import Icon from '../../src/components/Icon.brm';

  const files = [
    { id: 'notes', name: 'Notes.txt', kind: 'Text', size: 3 },
    { id: 'photo', name: 'Beach.jpg', kind: 'Image', size: 2400 },
    { id: 'budget', name: 'Budget.xlsx', kind: 'Sheet', size: 48 },
    { id: 'song', name: 'Song.mp3', kind: 'Audio', size: 5100 },
  ];
  let picked = $signal<Key[]>([]);
  let sort = $signal<SortDescriptor | null>({ column: 'name', direction: 'ascending' });
  const rows = $memo(
    sort
      ? [...files].sort((a, b) => {
          const order =
            sort!.column === 'size' ? a.size - b.size : a.name.localeCompare(b.name);
          return sort!.direction === 'ascending' ? order : -order;
        })
      : files,
  );
</script>

<Table class="ui-table" aria-label="Files" selectionMode="multiple" selectedKeys=$bind(picked) sortDescriptor=$bind(sort)>
  <TableHeader>
    <Column class="ui-column ui-select-column">
      <Checkbox slot="selection" class="ui-box" aria-label="Select all">
        <span class="ui-mark" aria-hidden="true"><Icon name="check" size=(12) /><Icon name="minus" size=(12) /></span>
      </Checkbox>
    </Column>
    <Column id="name" class="ui-column" isRowHeader allowsSorting>Name</Column>
    <Column class="ui-column">Kind</Column>
    <Column id="size" class="ui-column" allowsSorting>Size (kB)</Column>
  </TableHeader>
  <TableBody>
    #for (file of rows by file.id) {
      <Row id=(file.id) class="ui-tablerow">
        <Cell class="ui-cell">
          <Checkbox slot="selection" class="ui-box" aria-label=(`Select ${file.name}`)>
            <span class="ui-mark" aria-hidden="true"><Icon name="check" size=(12) /><Icon name="minus" size=(12) /></span>
          </Checkbox>
        </Cell>
        <Cell class="ui-cell">{file.name}</Cell>
        <Cell class="ui-cell">{file.kind}</Cell>
        <Cell class="ui-cell ui-number">{file.size}</Cell>
      </Row>
    }
  </TableBody>
</Table>
<p>Selected: {picked.join(', ') || 'none'}.</p>
<Table aria-label="Files" selectionMode="multiple" selectedKeys=$bind(picked) sortDescriptor=$bind(sort)>
  <TableHeader>
    <Column><Checkbox slot="selection" aria-label="Select all" /></Column>
    <Column id="name" isRowHeader allowsSorting>Name</Column>
    <Column id="size" allowsSorting>Size</Column>
  </TableHeader>
  <TableBody>
    #for (file of rows by file.id) {
      <Row id=(file.id)>
        <Cell><Checkbox slot="selection" aria-label=(`Select ${file.name}`) /></Cell>
        <Cell>{file.name}</Cell>
        <Cell>{file.size}</Cell>
      </Row>
    }
  </TableBody>
</Table>

Sorting

A Column with an id and allowsSorting sorts the table on a press, or Enter. sortDescriptor holds the column and the direction, { column, direction }, and onSortChange hears of changes. A press sorts ascending, then descending. The Table does not reorder the rows: sort them by sortDescriptor, as the demo does.

A sortable Column shows aria-sort, data-allows-sorting and data-sort-direction, which can draw an arrow:

[data-sort-direction='ascending']::after { content: ' ▲'; }
[data-sort-direction='descending']::after { content: ' ▼'; }

Elements

The Table renders a table with role="grid", a thead and a tbody with role="rowgroup", and tr rows. A Column is a th with role="columnheader". A Cell is a td with role="gridcell", or a th with role="rowheader" in the row header column. A Row shows the states of a GridListItem, and a Column shows data-pressed, data-hovered, data-focused and data-focus-visible.

Keyboard

Column resizing

NameKindModified
Notes.txtTextToday
Beach.jpgImageYesterday
Budget.xlsxSheetLast week
<script>
  import Cell from '@brumate/ui/Cell.brm';
  import Column from '@brumate/ui/Column.brm';
  import ColumnResizer from '@brumate/ui/ColumnResizer.brm';
  import ResizableTableContainer from '@brumate/ui/ResizableTableContainer.brm';
  import Row from '@brumate/ui/Row.brm';
  import Table from '@brumate/ui/Table.brm';
  import TableBody from '@brumate/ui/TableBody.brm';
  import TableHeader from '@brumate/ui/TableHeader.brm';

  const files = [
    { id: 'notes', name: 'Notes.txt', kind: 'Text', modified: 'Today' },
    { id: 'photo', name: 'Beach.jpg', kind: 'Image', modified: 'Yesterday' },
    { id: 'budget', name: 'Budget.xlsx', kind: 'Sheet', modified: 'Last week' },
  ];
</script>

<ResizableTableContainer class="ui-resizable">
  <Table class="ui-table" aria-label="Files">
    <TableHeader>
      <Column id="name" class="ui-column ui-resizable-column" isRowHeader defaultWidth=(160) minWidth=(100)>
        <span tabindex="-1">Name</span>
        <ColumnResizer class="ui-resizer" />
      </Column>
      <Column id="kind" class="ui-column ui-resizable-column" defaultWidth=(120) minWidth=(80)>
        <span tabindex="-1">Kind</span>
        <ColumnResizer class="ui-resizer" />
      </Column>
      <Column id="modified" class="ui-column">Modified</Column>
    </TableHeader>
    <TableBody>
      #for (file of files by file.id) {
        <Row id=(file.id) class="ui-tablerow">
          <Cell class="ui-cell">{file.name}</Cell>
          <Cell class="ui-cell">{file.kind}</Cell>
          <Cell class="ui-cell">{file.modified}</Cell>
        </Row>
      }
    </TableBody>
  </Table>
</ResizableTableContainer>
<ResizableTableContainer onResize=saveWidths>
  <Table aria-label="Files">
    <TableHeader>
      <Column id="name" isRowHeader defaultWidth=(160) minWidth=(100)>
        <span tabindex="-1">Name</span>
        <ColumnResizer />
      </Column>
      <Column id="kind">Kind</Column>
    </TableHeader>
    …
  </Table>
</ResizableTableContainer>