# Atscript UI Tables

> Render searchable, filterable, sortable, paginated, or virtualized tables from `.as` annotated types with `@atscript/vue-table` + `@atscript/ui-table`. Use when working with `<AsTableRoot>`, `<AsTable>`, `<AsWindowTable>`, `<AsFilters>`, `<AsPresetPicker>`, `<AsConfigDialog>`, or `<AsTableActions>`; when writing `@ui.table.*` / `@ui.table.fn.*` / `@ui.dict.*` annotations; when wiring a custom `queryFn` or a `moost-db` URL; when building custom cells via `@ui.table.component` + `:components` (and `provideCellLocale`); when persisting state to the URL (`useTableUrlQuery`) or to presets (`usePresets`, `useLocalDraft`, `useAppPrefs`, `AsPresetPicker`), including server-side via `AsPresetsController` from `@atscript/moost-ui-presets`; when wiring row/table actions (`AsActionFormDialog`) and selection (`togglePk`, `trimSelection`); or when tuning virtualization and block-aligned fetching. Out of scope: forms (use `atscript-ui-forms`), workflow forms (use `atscript-ui-wf`), styling (use `atscript-ui-styles`).

- Skill: `moostjs/atscript-ui-tables` (Agent Skill, multi-file: 10 files)
- Install (CLI): `npx skillmds@latest add moostjs/atscript-ui-tables`
- Raw SKILL.md: https://api.skillmd.com/api/skills/moostjs/atscript-ui-tables/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Web & Frontend
- Author: moostjs (https://skillmd.com/u/moostjs)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/moostjs/atscript-ui-tables

---


# atscript-ui-tables

## Install

```bash
npx skills add moostjs/atscript-ui      # installs all atscript-ui skills (this one + general + forms + wf + styles)
npx skills add moostjs/atscript         # sibling — .as language
npx skills add moostjs/atscript-db      # sibling — moost-db backs most tables
```

```bash
pnpm add @atscript/core @atscript/typescript @atscript/ui @atscript/ui-table @atscript/vue-table vue
pnpm add @atscript/vue-form                          # required peer (cell dispatch, action forms)
pnpm add @atscript/ui-fns                            # opt-in: dynamic @ui.table.fn.*
pnpm add @atscript/db-client                         # moost-db browser client
pnpm add @atscript/moost-ui-presets                  # server-side preset persistence (optional)
```

## Quick start

```atscript
// src/product.as
@db.table 'products'
@db.depth.limit 0
export interface Product {
    @meta.id @db.default.increment
    id: number

    @meta.label 'SKU'
    @ui.table.width '8em'
    sku: string

    @meta.label 'Name'
    @db.index.fulltext 'name_fts'
    name: string

    @meta.label 'Price'
    @db.column.precision 2
    @db.amount.currency 'USD'
    price: number

    @meta.label 'In stock'
    inStock: boolean
}
```

```vue
<script setup lang="ts">
import { createDefaultCellTypes } from "@atscript/vue-table";

const types = createDefaultCellTypes();
</script>

<template>
  <AsTableRoot url="/api/db/tables/products" :types="types" :limit="20">
    <AsTableActions />
    <AsFilters />
    <AsTable :column-menu="{ sort: true, filters: true, hide: true, resetWidth: true }" />
  </AsTableRoot>
</template>
```

Replace `url=` with `:query-fn="..."` for a custom backend. See [query.md](references/query.md).

## Invariants

| #   | Rule                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        |
| --- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 1   | **Programmatic state changes are picked up by the root watcher automatically.** Each mutator on `state` (e.g. `setFieldFilter`, `setColumnWidth`, `setSearchTerm`) touches exactly one entity; the root watcher on `[filters, sorters, pagination, columnNames]` schedules the next query. Calling `state.query()` to "apply" a programmatic change will double-fetch and skip debouncing.                                                                                                                                                                                                                                                                                                                  |
| 2   | **`state.query()` is reserved for user-initiated refresh.** Wire it to a refresh button, pull-to-refresh, or devtools — not to apply programmatic state changes — except `query({ silent: true })` for timer-driven live refresh (invariant 14).                                                                                                                                                                                                                                                                                                                                                                                                                                                            |
| 3   | **`filterFields` (display) and `filters` (applied) are independent.** Hiding a filter input via `filterFields` does NOT clear `filters[path]`. Clearing `filters[path]` does NOT hide the input. When building your own dialogs, write the new arrays directly — the root watcher reconciles. Cleanup loops that delete `filters` entries because `filterFields` shrank will fight the model and cause double-fetches.                                                                                                                                                                                                                                                                                      |
| 4   | **`@ui.table.type` is for built-in renderer ids only.** Built-ins: `text`, `number`, `boolean`, `date`, `datetime`, `relative`, `array`, `object`, `union`, `enum`, `ref`. Custom cells use `@ui.table.component` + the `:components` map.                                                                                                                                                                                                                                                                                                                                                                                                                                                                  |
| 5   | **Per-field filter conditions: inclusions OR-merge, exclusions AND-merge.** Inclusion ops (`eq`, `contains`, `starts`, `ends`, `gt`, `gte`, `lt`, `lte`, `bw`, `regex`, `null`) → OR within field. Exclusion ops (`ne`, `notNull`) → AND within field. Across fields → AND. See `filtersToUniqueryFilter` in `ui-table`.                                                                                                                                                                                                                                                                                                                                                                                    |
| 6   | **Force filters / sorters AND-merge; user can't remove them.** Pass via `useTable({ forceFilters, forceSorters })`; they always prepend and dedupe by field — user mutations to `filters` / `sorters` never override them.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  |
| 7   | **Window mode fetches in fixed-size blocks.** `<AsWindowTable>` issues block-aligned fetches (default 100 rows) as the viewport scrolls; tune via `<AsTableRoot :block-size>` and `:drag-release-debounce-ms` (higher = fewer fetches during fast scroll). Custom virtual renderers read rows via `state.dataAt(absIndex)` / `state.loadingAt(absIndex)` / `state.errorAt(absIndex)` — see [query.md](references/query.md).                                                                                                                                                                                                                                                                                 |
| 8   | **Presets opt-in per-aspect.** A `PresetSnapshot` carries any subset of `columns`, `filters`, `filterOps`, `sorters`, `itemsPerPage`. Absent keys leave that slice untouched on apply. Use `toWireSnapshot` / `fromWireSnapshot` when crossing the network — never send the raw runtime dict.                                                                                                                                                                                                                                                                                                                                                                                                               |
| 9   | **Reserved preset id prefixes**: `sys:` (system, client-only, never persisted), `uc:` (user config, deterministic id `uc:<user>:<app>:<tableKey>`), `ac:` (app config, deterministic `ac:<user>:<app>`). Client writes to `sys:*` are rejected by the server controller.                                                                                                                                                                                                                                                                                                                                                                                                                                    |
| 10  | **Server preset read gate**: `user = current OR (type='preset' AND public=true)`. Once-public-always-public — revoking publish permission doesn't unpublish existing rows.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  |
| 11  | **`/meta` response carries `preferredId` on every row-returning read.** `moost-db` widens `$select` automatically; cells/actions/refs can rely on identity. Aggregate (`$groupBy`) and `$count` responses are NOT widened — see atscript-db skill, invariant 10.                                                                                                                                                                                                                                                                                                                                                                                                                                            |
| 12  | **`:controls` takes only your overrides.** Every dispatch site falls back internally (`controls[key] ?? default`), so passing `createDefaultControls()` wholesale is redundant AND opts the lazy dialogs into eager bundling+mounting. `rowActions` is not seeded — the `__actions` column resolves `controls.rowActions ?? types.__actions ?? AsRowActions`.                                                                                                                                                                                                                                                                                                                                               |
| 13  | **`$sort` overrides search relevance — opt into `ignoreSortersWhenSearched` to preserve it.** A preset/`v-model`/header sort emitted alongside `$search` replaces relevance ranking on a scored backend (Atlas `$search`), so search returns the right row set in browse order. `<AsTableRoot :ignore-sorters-when-searched>` (default `false`) suppresses user sorters at query time while searching; `forceSorters` still emit. The flag is a model — sorting mid-search flips it off for the session; a new search resets it. Only for relevance-ranked backends. See [sorting-pagination.md](references/sorting-pagination.md#search-relevance-suppression).                                            |
| 14  | **`query({ silent: true })` is the sanctioned timer-driven live-refresh path.** Re-runs the current query (live filters/sorters/search/pagination/`$actions`) with no `querying` flip — no spinner, no skeletons, no query overlay. Loud-wins coalescing: a real (loud) query landing the same tick still shows the spinner. On failure, rows/`totalCount`/error state are left untouched — never blanks or toasts the grid. Preserves scroll position. Rides on keep-rows-until-settle — a stable contract where every query, silent or not, keeps prior rows visible until the response settles, then swaps `results` + `totalCount` atomically. See [query.md](references/query.md#silent-live-refresh). |

## Key imports

```ts
// Tier 1 — primary (auto-imported by AsResolver)
import {
  AsTableRoot,
  AsTable,
  AsWindowTable,
  AsTableActions,
  AsFilters,
  AsPresetPicker,
} from "@atscript/vue-table";

// Tier 2 — defaults (swap targets)
import {
  // cells
  AsCellArray,
  AsCellDate,
  AsCellJson,
  AsCellNumber,
  AsCellUnion,
  AsTableCellValue,
  // dialogs
  AsConfigDialog,
  AsFilterDialog,
  AsPresetDialog,
  AsConfirmDialog,
  // filter ui + headers + rows
  AsFilterField,
  AsFilterInput,
  AsTableHeaderCell,
  AsRowActions,
  AsColumnMenu,
} from "@atscript/vue-table";

// AsActionFormDialog is on a dedicated subpath — it pulls in @atscript/vue-form,
// so the table root lazy-mounts it. Import this only to override / eager-load.
import AsActionFormDialog from "@atscript/vue-table/as-action-form-dialog";

// Composables
import {
  useTable,
  useTableContext,
  useTableContextOptional,
  createTableState,
  createStaticTableState,
  useTableSelection,
  useTableNavBridge,
  useTableFilter,
  useTableSearch,
  useTableActions,
  useTableUrlQuery,
  useAppPrefs,
  usePresets,
  useLocalDraft,
  useCellLocale,
  provideCellLocale,
  useTableComponent,
} from "@atscript/vue-table";

// Factories
import { createDefaultControls, createDefaultCellTypes } from "@atscript/vue-table";

// Types (re-exported)
import type {
  TAsTableControls,
  TAsCellTypeComponents,
  ReactiveTableState,
  ColumnMenuConfig,
  ConfigTab,
  TableActionsState,
  ActionResult,
} from "@atscript/vue-table";

// Framework-agnostic table model (filter / preset / query primitives)
import {
  FilterCondition,
  FieldFilters,
  filtersToUniqueryFilter,
  PresetSnapshot,
  toWireSnapshot,
  fromWireSnapshot,
  buildTableQuery,
  mergeFilters,
  mergeSorters,
  stateToUrlQueryString,
  urlQueryStringToState,
} from "@atscript/ui-table";

// Server-side preset controller (Moost)
import { AsPresetsController, AsPresetEntry } from "@atscript/moost-ui-presets";
```

## References — load only what's needed

| Domain               | File                                                                       | When                                                                                                                                                                                                                                                                                                                                                                       |
| -------------------- | -------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| First contact        | [getting-started.md](references/getting-started.md)                        | Install matrix, `<AsTableRoot>` props, the default `:types` + `:controls` maps, slot binding contract                                                                                                                                                                                                                                                                      |
| Query / data wiring  | [query.md](references/query.md)                                            | `url=` (moost-db) vs `queryFn` (custom), `buildTableQuery` Uniquery assembly, force filters/sorters, meta endpoint, mutators-are-pure principle in detail                                                                                                                                                                                                                  |
| Filtering            | [filtering.md](references/filtering.md)                                    | Filter model (`FieldFilters` / `FilterCondition` / 13 condition types), OR/AND semantics, `filtersToUniqueryFilter` translation, `<AsFilters>` / `<AsFilterField>` / `<AsFilterDialog>`, value-help inside filter dialogs                                                                                                                                                  |
| Sorting + pagination | [sorting-pagination.md](references/sorting-pagination.md)                  | Sort model + multi-sort, header click semantics, `<AsConfigDialog>` sorters tab, paginated `<AsTable>` vs virtualized `<AsWindowTable>`, block-aligned fetching, `dragReleaseDebounceMs` tuning                                                                                                                                                                            |
| Cells                | [cells.md](references/cells.md)                                            | Built-in cell components + default type map, `provideCellLocale` (language + timezone), custom cells via `@ui.table.component` + `:components`, slot API (`#header-<path>`, `#cell-<path>`, `#empty`, `#query-loading`, `#error`), per-cell styling via `@ui.table.{classes,styles,attr}`                                                                                  |
| State persistence    | [state-persistence.md](references/state-persistence.md)                    | `<AsConfigDialog>` tabs (columns/sorters/filters), `useTableUrlQuery` (router two-way bind), client presets (`PresetSnapshot`, `useLocalDraft`, `usePresets`, `useAppPrefs`, `<AsPresetPicker>`, system/user/public, `dateShortcuts`), server presets via `AsPresetsController`                                                                                            |
| Actions + selection  | [actions-selection.md](references/actions-selection.md)                    | Row / table actions on the `.as` type, `<AsActionFormDialog>` (action input form via vue-form), `state.selectedRows` (`Set<PK>`), `togglePk` / `trimSelection` / `rowsToPks`, `state.actions.invoke(action, pk?, opts?)`, the `__actions` synthetic column; navigate-action links (`navigateHrefFor`, `resolveHref`, anchor vs button, native new-tab silent to `@action`) |
| Customization        | [customization.md](references/customization.md)                            | When swapping table chrome: per-column / state slots (`#cell-*`, `#header-*`, `#empty`, `#error`, `#last-row`), `:types` / `:components` cell swap, replacing dialogs via `:controls` (and which control keys actually dispatch), custom row-actions cell, rendering without a header row (`:headless`)                                                                    |
| Edit form + OCC      | [edit-form-occ.md](references/edit-form-occ.md)                            | When building a row-edit `<AsForm>` against an OCC table: `@db.column.version`, `meta.versionColumn` → `createFormDef`, catching `VersionMismatchError` / `currentVersion` on submit                                                                                                                                                                                       |
| Annotation catalog   | [annotations.md](../atscript-ui/references/annotations.md)                 | When writing or looking up any `@ui.table.*` / `@ui.table.fn.*` / `@ui.dict.*` annotation — args, constants, defaults (SSOT lives in the root `atscript-ui` skill)                                                                                                                                                                                                         |
| Bundle optimization  | [bundle-optimization.md](../atscript-ui/references/bundle-optimization.md) | When asked about table bundle size: lazy dialog latches, `controls.X` flipping a dialog eager, `AsActionFormDialog` pulling in vue-form, shedding unused chrome CSS via `excludeComponents` (SSOT lives in the root `atscript-ui` skill)                                                                                                                                   |

## Customization

Full swap-surface reference (slots, maps, dispatched control keys): [customization.md](references/customization.md).

Tables expose three swap surfaces, layered on the tier model:

- **Tier 1** — `<AsTableRoot>`, `<AsTable>`, `<AsWindowTable>`, `<AsFilters>`, `<AsTableActions>`, `<AsPresetPicker>` are the integration surface. Build with `useTable` / `createTableState` directly if you need a custom shell.
- **Tier 2** — default cells (`AsCellArray`, `AsCellDate`, `AsCellJson`, `AsCellNumber`, `AsCellUnion`, `AsTableCellValue`) and default dialogs (`AsConfigDialog`, `AsFilterDialog`, `AsPresetDialog`, `AsConfirmDialog`, `AsRowActions`, `AsColumnMenu`, `AsTableHeaderCell`). These are what you swap.
- **Tier 3** — internal composition (header cells, virtualizer pieces, fields selector, sorter config). Not tagged directly; styles ride with the defaults that use them.

### Swap a built-in cell renderer (`:types`)

`:types` maps built-in cell ids (per invariant 4: `text`, `number`, `boolean`, `date`, `datetime`, `relative`, `array`, `object`, `union`, `enum`, `ref`) to a component:

```vue
<script setup lang="ts">
import { createDefaultCellTypes } from "@atscript/vue-table";
import MyDateCell from "./MyDateCell.vue";

const types = { ...createDefaultCellTypes(), date: MyDateCell, datetime: MyDateCell };
</script>

<template>
  <AsTableRoot url="/api/db/tables/products" :types="types" />
</template>
```

### Swap a specific cell (`:components` + `@ui.table.component`)

For column-specific cells, opt in on the `.as` type and supply the named component:

```atscript
@ui.table.component 'price-tag'
price: number
```

```vue
<script setup lang="ts">
import PriceTag from "./PriceTag.vue";
const components = { "price-tag": PriceTag };
</script>

<template>
  <AsTableRoot url="..." :components="components" />
</template>
```

Wrap a cell in `useCellLocale` / `provideCellLocale` if it needs locale + timezone from `useAppPrefs`.

### Swap a dialog (`controls.*`)

The toolbar, config, filter, and preset dialogs are swappable through `controls`. Pass only the entries you replace — spreading `createDefaultControls()` defeats dialog lazy-loading (see invariant 12):

```vue
<script setup lang="ts">
import MyFilterDialog from "./MyFilterDialog.vue";

const controls = { filterDialog: MyFilterDialog };
</script>

<template>
  <AsTableRoot url="..." :controls="controls" />
</template>
```

Custom dialogs just write the new arrays back to `state.filterFields` / `state.filters` / `state.sorters` / `state.columnNames` — the root watcher reconciles and re-queries (see invariants 1–3 and [query.md](references/query.md)). Don't call `state.query()` or run cleanup loops mirroring display state into applied state; both fight the watcher.

### Style consequence

Replacing `AsFilterDialog` with a custom dialog that doesn't tag the `as-filter-*` shortcuts drops those classes out of your bundle automatically. Keep the default whenever its chrome fits — you'll spend less time on style maintenance. For granular opt-out, `atscript-ui-styles` ships per-domain shortcut groups (`tableShortcuts`, `formShortcuts`, …) you can compose narrower than the default `allShortcuts`.

## See also

Reference docs: https://ui.atscript.dev/tables/. Source: https://github.com/moostjs/atscript-ui.

