# Semantic tables in Vue / Persona workspaces

This recipe closes the three gaps in `ebs/thor-dashboard/docs/design-system-gaps.md`: page-size controls, compact page-heading counts and framework/remote-table integration. It was checked against the local `CustomPersonaTableStatic` wrapper and Enterprise Accounts, Master products and Users pages on 27 October 2026. It is guidance for the existing component, not a replacement table package or a change to its business/API behavior.

Working specimens: [Table pagination & remote states](index.html#table-pagination) and [Compact page-heading summaries](index.html#heading-summaries). The table uses timer-based local fixtures to demonstrate remote request semantics; it does not call EBS APIs.

## Footer placement and dimensions

Put page size **inside `.pagination-footer`**, outside the table's horizontal scroll region. Keep the loaded range on the left, and a right-aligned control group containing **Rows per page**, then Previous/numbered pages/Next. This preserves page navigation at the far right. Do not duplicate page size in a toolbar unless the product deliberately removes it from the footer.

- Visible and accessible label: **Rows per page**. Associate it with a unique control ID for each table instance.
- Default options for EBS: 10, 20, 30, 40, 50. Default size 10. Products may use another approved server-supported set, but the current value must be present in the options.
- Select: 88px width, 48px minimum height, shared pill radius, semantic input border and background. Keep 8px between label and select, 24px between page-size and pagination groups and 20px between footer blocks.
- Range: 12px/18px semantic secondary text, such as `21–40 of 103 · Page 2 of 6`. Compute it from the **matching server total** and current successful page, not from the number of returned rows alone.
- Changing page size resets page to 1. Search, filter and sort changes also reset page to 1. Paging preserves the other query values.
- Below 768px, range moves to its own row and the controls wrap below. At 440px and below, page size and page navigation occupy separate rows. Keep page navigation aligned right. The footer itself never scrolls horizontally with the table.
- Page-size selection remains available after an empty result or error. Do not hide the complete footer just because `items.length === 0`.
- During loading or error, total is unknown: show `Loading records…` or `Result count unavailable`, and disable navigation. Never substitute a false zero. Successful empty results show `0–0 of 0`, with paging disabled.

The HTML kit uses its searchable custom select. In Vue, implement the same behavior using the framework's supported control. Do not run the catalogue DOM-enhancement script over Vue-owned controls; that script is a standalone demo implementation.

## Compact page-heading summary

Use a semantic `dl` with three to five labeled counts beside the title/description. Each metric is a compact bordered surface with 12px/16px padding, 12px corners, 8px between metrics, 16px/24px semibold values and 12px/18px secondary labels. Keep approximately 106px minimum width per item on wide screens; allow longer labels to wrap. Header spacing is 24px, with 24px padding (20px mobile).

At narrow widths, put the title above a two-column metric grid; a final odd metric may occupy the last row. Counts are read-only unless a separate documented filter interaction is provided. Do not give plain metrics button hover or focus styling.

The specimen shows Role Ebs, Super Admins, Sales Leads and Sales Officers. Counts refer to the **whole permitted workspace**, independently of table page/search; label this scope. Role assignment counts can overlap, so they must not be presented or summed as unique users. Do not compute workspace-wide counts from one returned page. Use a separate summary response or server aggregation with the same authorization scope.

Loading uses count-shaped skeletons and one live status announcement. Successful empty counts display `0`. Error/unavailable counts display `—` with an accessible Unavailable label and Retry. Do not render old values as current after a failed scope change.

## Mapping the current EBS wrapper

Observed implementation: `app/components/custom-persona/table/static/index.vue`.

| Existing API | Responsibility |
| --- | --- |
| `items` | The rows to render **as supplied**. The wrapper maps row keys; it does not slice the collection by page. |
| `fields` | Column keys/labels, width and cell/header classes. Keep semantic `th scope="col"` and `td` output. |
| `caption` | Table's accessible name/context; use a specific label per workspace. |
| `isLoading` | Skeleton tbody and busy indicator. Keep error separate in the owning page. |
| `total` | Matching full-result count from the server, or full filtered count in client mode. |
| `page`, `update:page` | One-based page state. Clamp to available pages after successful data changes. |
| `perPage`, `update:perPage`, `perPageOptions` | Numeric page size and approved choices. The wrapper also emits page 1 on a size change. |
| `hidePagination` | Hides the whole footer. Do not use this merely for empty/error/loading states. |
| `scrollable`, `tableClass`, `trClass` | Scoped overflow and presentation hooks. Theme colors belong in tokens/CSS. |
| `head(key)` | Custom heading content, including a native sorting button. |
| `cell(key)` | Custom cell content such as identity, badges, amount, date or named row actions. |
| `empty` | Separate successful-empty and failed-load presentation using the page's explicit error state. |
| `rowClickable`, `row-click` | Optional row-level navigation. Explicit row buttons are preferable when a row also contains other controls. |
| `modelValue`, `update:modelValue` | Selection state. Define whether this is current-page-only; use stable IDs for cross-page selection. |

Keep the existing prop spelling `apperance` when using that wrapper; this guide does not introduce a new `appearance` prop.

### Observed integration details to address when adopting the recipe

1. The current footer is gated by `items?.length > 0`. Adjust this in the application adapter to retain page size and meaningful status after empty/error results.
2. The wrapper uses the fixed `table-page-label` ID. Generate a unique ID per rendered table when composing multiple tables.
3. Header slots replace only the content inside `th`. The current wrapper has no sort-state prop or header-attribute hook. Add an explicit `sortBy`/`sortDirection` mapping or a header-attributes hook so `aria-sort` lands on the actual `th`; putting it on the nested button is not equivalent.
4. The wrapper renders `items` directly. Users/Enterprise can pass an API page unchanged. The inspected Master products page passes `filteredItems` and `filteredItems.length`: if that list is the complete local dataset, the parent must pass a sliced page while retaining the full filtered count. Never slice an API page a second time.
5. Some inspected responses fall back to `items.length` when `meta.total` is absent. That is not a valid total for server pagination. Require or normalize the actual total; if the API intentionally has unknown totals, use a separately documented cursor/Next pattern instead of inventing numbered pages.
6. Row-keydown handling must exclude nested buttons/links/inputs **before** calling `preventDefault()`. A blanket `.prevent` on the row can consume Space/Enter intended for a child checkbox or button. Check nested actions with keyboard tests.

These are adoption notes for the consuming project. No application component is modified by adding the shared specimens.

## One query owner; client and remote modes stay distinct

Remote query contract:

```ts
type TableQuery = {
  page: number
  per_page: number
  search: string
  role: string
  sort_by: 'name' | 'id'
  sort_dir: 'asc' | 'desc'
}
type PageResult<T> = { items: T[]; total: number }
```

Use the application's existing endpoint/field mapping. The names above are a normalized view model, not a claim that every EBS endpoint supports identical parameters. Unsupported server sorting/filtering must not silently fall back to sorting only the visible page.

| Mode | Processing order | Rows passed to the wrapper |
| --- | --- | --- |
| Client | Complete permitted dataset → filter → stable sort → total → slice | Exactly the current slice |
| Remote | Query → server authorization/filter/sort/total/page → adapter | Exactly the returned page |

Keep one owner for the query. Do not run an additional client-side sorter over a server page. Scope filters and sort keys should be allowlisted by the API. Use a stable secondary key to avoid row order changing between equal sort values.

## Vue request controller recipe

The example below accepts an API adapter; it invents no endpoint and imports no catalogue script. The adapter must normalize the application's response into `items` and `total` and forward `signal` when supported. Watcher cleanup is documented by [Vue](https://vuejs.org/guide/essentials/watchers.html#side-effect-cleanup).

```ts
import { ref, shallowRef, watch } from 'vue'

export function useTablePage<T>(
  loadPage: (query: TableQuery, signal: AbortSignal) => Promise<PageResult<T>>,
) {
  const query = shallowRef<TableQuery>({
    page: 1, per_page: 10, search: '', role: 'all',
    sort_by: 'name', sort_dir: 'asc',
  })
  const rows = shallowRef<T[]>([])
  const total = ref<number | null>(null)
  const state = ref<'loading' | 'ready' | 'error'>('loading')
  const error = ref('')
  const retryKey = ref(0)
  let requestId = 0

  watch([query, retryKey], async ([snapshot], _old, onCleanup) => {
    const id = ++requestId
    const controller = new AbortController()
    onCleanup(() => controller.abort())
    const current = () => id === requestId
      && query.value === snapshot && !controller.signal.aborted

    state.value = 'loading'
    error.value = ''
    rows.value = []
    total.value = null
    try {
      const result = await loadPage({ ...snapshot }, controller.signal)
      if (!current()) return
      if (!Array.isArray(result.items)
        || !Number.isSafeInteger(result.total) || result.total < 0)
        throw new Error('Invalid page response')

      const lastPage = Math.max(1, Math.ceil(result.total / snapshot.per_page))
      if (snapshot.page > lastPage) {
        // The result set shrank. Refetch the valid page; do not relabel old rows.
        query.value = { ...snapshot, page: lastPage }
        return
      }
      rows.value = result.items
      total.value = result.total
      state.value = 'ready'
    } catch {
      if (!current()) return
      state.value = 'error'
      error.value = 'Could not load records. Try again.'
    }
  }, { immediate: true })

  function setPage(page: number) {
    if (!Number.isInteger(page) || page < 1 || page === query.value.page) return
    query.value = { ...query.value, page }
  }
  function setPageSize(per_page: number) {
    if (![10, 20, 30, 40, 50].includes(per_page)) return
    query.value = { ...query.value, per_page, page: 1 }
  }
  function applyFilters(search: string, role: string) {
    query.value = { ...query.value, search: search.trim(), role, page: 1 }
  }
  function setSort(sort_by: TableQuery['sort_by'], sort_dir: TableQuery['sort_dir']) {
    query.value = { ...query.value, sort_by, sort_dir, page: 1 }
  }
  return { query, rows, total, state, error, setPage, setPageSize,
    applyFilters, setSort, retry: () => retryKey.value++ }
}
```

Bind `:items="rows"`, `:page="query.page"`, `:per-page="query.per_page"`, `:total="total ?? 0"` and `:is-loading="state === 'loading'"`; use the update events to call `setPage`/`setPageSize`. The adapter/footer must also consume the loading/error state so that the temporary numeric prop fallback is **not presented as a confirmed zero**. Batch page-size/page updates through the same owner; do not attach duplicate fetch watchers to both events. On scope/search/filter changes, clear any page-only selection.

The guarded response also protects adapters that cannot cancel the underlying request. An old failure must not erase a newer success, and an old success must not replace a newer query. Retry keeps the current query. Reset filters may preserve page size and sort, but its label and documented behavior must agree.

## State rendering and cells

- **Loading:** real rows are replaced by structural skeleton rows; mark the table/wrapper `aria-busy=true`. Skeletons are decorative (`aria-hidden`) and contain no focusable controls. Announce loading once outside the busy subtree.
- **Ready:** render the response rows. Keep pagination counts consistent with that same response.
- **Empty:** only after a successful response. Distinguish a genuinely empty collection from no matches; provide an appropriate reset/create action.
- **Error:** show the failed-load message and Retry; preserve query state and do not display fixture/old data as fresh. Use one announcement, not repeated alerts in every cell.
- **Retained snapshot alternative:** if a product deliberately keeps old rows while fetching, label them stale and disable mutations. Never mix old rows with the new query's total/page label.

Use semantic table markup with captions and `th scope=col`. Put sort controls in native buttons and `aria-sort` only on the active sorted column header, following the [WAI sortable-table pattern](https://www.w3.org/WAI/ARIA/apg/patterns/table/examples/sortable-table/). Name row actions with their record, such as “View details for Workspace user 012”. Ensure slot output stays inside valid cells; an empty row uses a colspan including the selection column when present. Label selection checkboxes with record identity and compute indeterminate state from eligible IDs, not raw counts alone.

## Tokens versus component configuration

| Apply through tokens / scoped CSS | Apply through props, slots or page state |
| --- | --- |
| DM Sans, 14px/20px cells, 12px/18px headings | Field keys, labels, widths and visible columns |
| Canvas header, surface rows, semantic text/borders | Caption, permitted row actions and selection policy |
| 18px vertical / 12px horizontal cell padding | Page, per-page, total and normalized server query |
| Branded focus, hover, pill controls and skeleton colors | Loading/error/empty state and retry behavior |
| 48px selects, footer gaps and responsive wrapping | Search draft versus submitted query, sorting events |

The table scroll container owns horizontal overflow; keep the page width bounded, the footer outside the scroller and every row action keyboard reachable. Do not remove header semantics to make a mobile card view. Preserve explicit labels if a product offers a separate card presentation. Loading motion respects `prefers-reduced-motion` through the shared skeleton rule.

## Acceptance checklist

Test numeric page sizes, last-page ranges, reset-to-first-page behavior, filters that return zero results, API errors distinct from zero, retry with unchanged query, slow/out-of-order responses, server-total changes, stable row actions and focus, multiple table instances, reduced motion, light/dark themes and 320px layouts. The catalogue check is `node scripts/ebs-patterns-check.mjs`; it validates the local interaction contract, not the production endpoints or Persona package internals.

Pagination geometry: use equal explicit width/height/minimums with zero padding and centered labels; the shared EBS specimen uses 44px circles, reducing to 32px below 440px. Apply page-size width to the custom-select wrapper as well as the native fallback.
