# data-table

Sortable, filterable tables from plain custom tags — one behavior owner
(`<data-table>` + `<data-th>`), everything else is CSS.


```html
<data-table>
  <data-thead>
    <data-tr>
      <data-th column-type="string" sort-direction="asc">Name</data-th>
      <data-th column-type="number">Age</data-th>
    </data-tr>
  </data-thead>
  <data-tbody>
    <data-tr><data-td>Beatrice</data-td><data-td>44</data-td></data-tr>
    <data-tr><data-td>Cynthia</data-td><data-td>41</data-td></data-tr>
    <data-tr><data-td>Beau</data-td><data-td>29</data-td></data-tr>
    <data-tr><data-td>Adam</data-td><data-td>36</data-td></data-tr>
  </data-tbody>
</data-table>
```


## Features

- **Sort** Click a `<data-th>` to visually sort by string, number, or date
- **Filter** `filter-value` hides non-matching rows
- **Export** The `--export` command downloads visible / all rows as CSV / JSON
- **DOM-stable** Sort / filter via CSS only. Does not conflict with DOM owners, such as Quark.
- **Bindable counts** `.provision` is `{ totalRows, visibleRows, sortColumnIndex, sortDirection, filterValue }` — a "12 of 40 rows" readout is one Quark rule
- **Plain structural tags** `<data-thead>` / `<data-tbody>` / `<data-tr>` /
  `<data-td>` are CSS-only — no registration cost

## Installation


`@excom/data-table` v0.1.5

```bash
pnpm add @excom/data-table
```

```bash
npm install @excom/data-table
```

```bash
yarn add @excom/data-table
```

### Import

```ts
import "@excom/data-table";
```



## Usage

Only `<data-table>` and `<data-th>` are registered custom elements.
`<data-thead>`, `<data-tbody>`, `<data-tr>`, `<data-td>`, `<data-tfoot>`, and `<data-tf>` are plain tags — this package's CSS styles them as a table (or apply the equivalent `.tag-data-*` classes).

Sort and filter are visual only (CSS `order` / `display`).
Row nodes never move or leave the DOM, so Quark bindings and `iterate()` tables keep working.

```html
<data-table>
  <data-thead>
    <data-tr>
      <data-th column-type="string" sort-direction="asc">Name</data-th>
      <data-th column-type="number">Age</data-th>
    </data-tr>
  </data-thead>
  <data-tbody>
    <data-tr><data-td>Adam</data-td><data-td>36</data-td></data-tr>
    <data-tr><data-td>Beau</data-td><data-td>29</data-td></data-tr>
  </data-tbody>
</data-table>
```

`<data-tbody>` is required — sorting and filtering both operate on its
`<data-tr>` children.

`.provision` reports the row counts and the active sort / filter, recomputed after connect, after a sort, and after every filter change.
Read it from a rule matching the table:

```quark
data-table {
  $visible: prop("provision").visibleRows;
  $total: prop("provision").totalRows;
  [bind-count] { content: "#{$visible} of #{$total} rows"; }
}
```

### API Reference


#### Attributes

| Name | Surface | Type | Default | Values | Description |
| --- | --- | --- | --- | --- | --- |
| `filter-value` | option | `string` |  |  | Hides `data-tr` rows (via `--data-tr-display: none`) whose text content doesn't include this value. Case-insensitive unless `filter-casing` is set. Unset / empty clears the filter. Rows stay in the DOM so Quark bindings survive. |
| `filter-casing` | option | `boolean` |  |  | Match `filter-value` case-sensitively instead of the default case-insensitive comparison. |

#### Provision

| Name | Type | Description |
| --- | --- | --- |
| `provision` | `DataTableProvision` (`{ totalRows: number; visibleRows: number; sortColumnIndex: number \| null; sortDirection: "asc" \| "desc" \| null; filterValue: string \| null; }`) | `{ totalRows, visibleRows, sortColumnIndex, sortDirection, filterValue }` — recomputed after connect, after the `data-table-sort` default action, and after every filter change. Not reflected as an attribute. |

#### Recognized Elements

| Relationship | Selector | Required | Description |
| --- | --- | --- | --- |
| `data-th` | descendant | yes | Sortable column header. Clicking toggles its `sort-direction` and fires `data-th-sort`, which becomes the active column. |
| `data-tbody` | descendant | yes | Required row container. Sorting and filtering both operate on its `data-tr` children. |
| `data-tr` | descendant | yes | Row, direct child of `data-tbody`. Gets `--data-tr-order` on sort and `--data-tr-display` on filter. DOM order is unchanged. |
| `data-td` | descendant | yes | Cell within a `data-tr`, read as sortable / export cell content. |

#### Fires

| Name | Type | Description |
| --- | --- | --- |
| `data-table-sort` | `DataTableSortEvent` (`CustomEvent & { type: "data-table-sort"; detail: { sortDirection: "asc" \| "desc"; columnType: "string" \| "number" \| "date"; columnIndex: number; sortFn: (a: string, b: string) => number; rows: HTMLElement[]; }; bubbles: true; cancelable: true; composed: true }`) | Cancelable. Dispatched when the active sort column / direction changes: on connect (if a `data-th` already has `sort-direction`) and after every `data-th-sort`. Call `preventDefault()` to take over sorting yourself. |

#### Listens for

| Name | Type | Description |
| --- | --- | --- |
| `data-th-sort` | `DataThSortEvent` (`CustomEvent & { type: "data-th-sort"; detail: void; bubbles: true; cancelable: true; composed: true }`) | Bubbled up from a descendant `data-th` when its `sort-direction` changes. Sets that header as the active sort column (clearing `sort-direction` from the previously active one) and emits `data-table-sort`. |

#### Commands

| Command | Action |
| --- | --- |
| `--export` | Builds a file from the table's `data-tr` / `data-td` / `data-th` text content and downloads it. Options are `data-*` on the invoker: `data-file-type` (`csv`, the default, or `json`), `data-file-name` (default `export_table_<locale-date>`), and `data-full` to download every row in DOM order instead of only the visible rows in visual sort order. Filtering needs no command: write `filter-value` / `filter-casing`. |

#### Default actions

| Event | Default behavior (unless preventDefault() is called) |
| --- | --- |
| `data-table-sort` | Sorts `event.detail.rows` by `columnIndex` with `sortFn` and sets `--data-tr-order` on each row (visual CSS `order` — DOM order is unchanged). |

#### CSS Custom Properties

| Name | Syntax | Default | Description |
| --- | --- | --- | --- |
| `--data-table-cols` | `<integer>` | `1` | Column count for shared subgrid tracks. Auto-detected from the widest row; set on the host to override. |

#### CSS Aliases

| Alias | Kind | Matches | Description |
| --- | --- | --- | --- |
| `:--data-table` | element | `data-table`, `.tag-data-table` |  |
| `:--data-thead` | element | `data-thead`, `.tag-data-thead` |  |
| `:--data-tbody` | element | `data-tbody`, `.tag-data-tbody` |  |
| `:--data-tfoot` | element | `data-tfoot`, `.tag-data-tfoot` |  |
| `:--data-tr` | element | `data-tr`, `.tag-data-tr` |  |
| `:--data-td` | element | `data-td`, `.tag-data-td` |  |
| `:--data-tf` | element | `data-tf`, `.tag-data-tf` |  |



### Examples

#### Filter rows + export as CSV

Filtering is State: write `filter-value` / `filter-casing` on the table — here a Quark `@on input` block copies the search field into them. Matching is case-insensitive unless `filter-casing` is set.

Invoke `--export` on the table (`<button command="--export" commandfor="…">`) to download its visible rows (visual sort order). The button's `data-file-type` is `csv` (default) or `json`; `data-file-name` sets the download name; `data-full` downloads every row in DOM order, regardless of filtering / sorting.

The first export button is the happy path (the table as you see it, including active filter / sort). The form below it writes `data-file-type` / `data-file-name` / `data-full` onto its button.


```html
<div>
  <quark-sheet>
    :scope {
      @on input (target: "form[data-filter]") {
        data-table { filter-value: target.elements.filterValue.value; }
      }
      @on change (target: "form[data-filter]") {
        data-table { filter-casing: target.elements.filterCasing.checked; }
      }
      @on change (target: "form[data-export]") {
        form[data-export] [command="--export"] {
          data-file-type: target.elements.fileType.value;
          data-file-name: target.elements.fileName.value;
          data-full: target.elements.full.checked;
        }
      }
    }
  </quark-sheet>
  <form data-filter>
    <fieldset role="group">
      <input name="filterValue" placeholder="Filter table…" />
      <label>
        Case-sensitive
        <input name="filterCasing" role="switch" type="checkbox" />
      </label>
    </fieldset>
  </form>
  <data-table id="filter-people-table">
    <data-thead>
      <data-tr>
        <data-th column-type="string">Name</data-th>
        <data-th column-type="number">Age</data-th>
      </data-tr>
    </data-thead>
    <data-tbody>
      <data-tr><data-td>Beatrice</data-td><data-td>44</data-td></data-tr>
      <data-tr><data-td>Cynthia</data-td><data-td>41</data-td></data-tr>
      <data-tr><data-td>Beau</data-td><data-td>29</data-td></data-tr>
      <data-tr><data-td>Adam</data-td><data-td>36</data-td></data-tr>
    </data-tbody>
  </data-table>
  <hr />
  <button type="button" command="--export" commandfor="filter-people-table">
    Export as-is
  </button>
  <hr />
  <form data-export class="tag-article">
    <header>
      <h4>Export with options</h4>
    </header>
    <label>
      <select name="fileType">
        <option value="csv" selected>CSV</option>
        <option value="json">JSON</option>
      </select>
    </label>
    <label>
      File name
      <input name="fileName" placeholder="File name" />
    </label>
    <label>
      <input name="full" type="checkbox" role="switch" />
      Full table (ignores active filter/sort)
    </label>
    <button type="button" command="--export" commandfor="filter-people-table">Export</button>
  </form>
</div>
```

## Release notes

### 0.1.1 (2026-09-23)

- Ship only dist (and declared extras) in the npm tarball; drop build logs, tests and sources
