Data Table System

8 composable sub-components that assemble into sortable, searchable, expandable data tables. Used throughout the product app for traffic connections, requests, usage, and inventory lists.

Component Map

Every data table is assembled from these pieces. The root UxTableList provides the table element and scroll detection. Everything else slots in.

UxTableList
#header slot
UxTableListColumnHeader auto-delegates to:
UxTableListSorter
UxTableListSearch
separator row (bg-black h-[5px])
UxTableListTotals (optional)
separator row (bg-grey-200 h-[2px])
default slot
UxTableListExpandRow → #details slot
UxTableListToggleRow → #expanded slot

Start Here: Flat Composition

The simplest working table. Everything in one file — sort metrics drive the header, UxTableListColumnHeader auto-delegates to a Sorter or Search depending on whether the metric has a search callback.

requests
bytes in
errors
availability
8 vendors216,68593.2 MB9499.96%
A
s3.amazonaws.com
56,78045.0 MB4299.92%
G
api.github.com
45,23012.3 MB2399.95%
S
api.stripe.com
32,4568.7 MB1299.99%
C
api.cloudflare.com
28,90115.2 MB599.98%
D
api.datadoghq.com
23,4505.6 MB199.99%
T
api.twilio.com
15,6783.4 MB899.9%
S
api.sendgrid.com
9,8702.1 MB399.97%
S
hooks.slack.com
4,320890.0 KB0100%
<UxTableList>
  <template #header>
    <tr>
      <UxTableListColumnHeader
        v-for="metric in sortMetrics"
        :key="metric.name"
        :metric="metric"
        :sortBy="sortBy"
        @sortUp="onSort(metric.name, 'up')"
        @sortDown="onSort(metric.name, 'down')"
      />
    </tr>
    <tr><th class="h-[5px] p-0 bg-black" colspan="100%"></th></tr>
    <UxTableListTotals>
      <td>{{ vendorCount }} vendors</td>
      <td>{{ totalRequests }}</td>
    </UxTableListTotals>
    <tr><th class="h-[2px] p-0 bg-grey-200" colspan="100%"></th></tr>
  </template>

  <UxTableListExpandRow v-for="item in items" :key="item.id">
    <td>{{ item.domain }}</td>
    <td>{{ item.requests }}</td>

    <template #details>
      <div class="p-6 bg-white shadow-lg rounded-12">
        Detail content here
      </div>
    </template>
  </UxTableListExpandRow>
</UxTableList>

<script setup>
const sortMetrics = [
  { name: 'vendor', width: '200px', search: (needle) => { search.value = needle } },
  { name: 'requests', width: '120px' },
]
</script>

Scaling Up: Production Decomposition

In production, a data table spans 6 files. Each layer has one job. This is the architecture from traffic/connections in the product app.

Layer
File
Responsibility
Page
connections/index.vue
Layout + filter gate
Container
list/Index.vue
Data loading, scroll state, auto-refresh
UI
list/UI.vue
UxTableList wrapper, event pass-through
SortHeader
list/SortHeader.vue
Search + sorter columns from metrics
ItemUI
list/ItemUI.vue
ExpandRow with cells + #details slot
ItemDetails
list/ItemDetails.vue
Expanded content, decoupled from table

Layer 1: Page

The page is thin — just layout, filters, and a gate that waits for filter context before rendering the list.

<!-- connections/index.vue -->
<template>
  <NuxtLayout name="global" left-nav="traffic">
    <template #filter>
      <FilterDatasetView @ready="datasetViewReady = true" />
      <TrafficFilter @ready="filtersReady = true" />
    </template>

    <PageTitle :breadcrumb="['Traffic']">Connections</PageTitle>

    <!-- Only render once filters are ready -->
    <TrafficConnectionsList v-if="filterContextReady" />
  </NuxtLayout>
</template>

<script setup>
const filtersReady = ref(false)
const datasetViewReady = ref(false)
const filterContextReady = computed(() => filtersReady.value && datasetViewReady.value)
</script>

Layer 2: Container

Owns the data composable and all state management. Defines the sort metrics array, manages scroll position for auto-refresh, and tracks open items to prevent refresh while a user is reading details.

<!-- list/Index.vue — owns data + state -->
<template>
  <TrafficConnectionsListUI
    v-bind="{ defaultSortMetrics, isEndpointContext }"
    v-model:search="search"
    @scrollAtTop="onScrollAtTop"
    @scrollingDown="onScrollingDown"
    @scrollAtBottom="onScrollAtBottom"
    :isLoadingNewLogs
    :isLoadingOldLogs
  >
    <TrafficConnectionsListItemUI
      v-for="log in auditLogs"
      :key="log.id"
      :auditLog="log"
      @detailsOpen="onItemOpen"
      @detailsClose="onItemClose"
    />
  </TrafficConnectionsListUI>
</template>

<script setup>
// Data composable — provides items, load, loadMore, search
const { items: auditLogs, load, loadMore, search } = useAuditList(orgId, {
  autoload: true,
  limit: 100,
  ...trafficQuery,
})

// Column definitions
const defaultSortMetrics = ref([
  { name: 'executable' },
  { name: 'type',        width: '0.1%' },
  { name: 'source',      width: '0.5%' },
  { name: 'action',      width: '10%' },
  { name: 'destination', width: '15%' },
  { name: 'timestamp',   width: '0.1%' },
])

// Auto-refresh every 10s when at top + no items expanded
const atTop = ref(true)
const totalOpenItems = ref(0)

setInterval(() => {
  if (atTop.value && totalOpenItems.value === 0) load()
}, 10000)

const onItemOpen = () => totalOpenItems.value++
const onItemClose = () => totalOpenItems.value--
const onScrollAtTop = () => { atTop.value = true }
const onScrollAtBottom = () => { loadMore() }
</script>

Layer 3: UI Wrapper

Wraps UxTableList with scroll events and loading indicators. Delegates the header to a SortHeader component and passes items through the default slot.

<!-- list/UI.vue — wraps UxTableList -->
<template>
  <UxTableList
    @scrollAtTop="emit('scrollAtTop')"
    @scrollingDown="emit('scrollingDown')"
    @scrollAtBottom="emit('scrollAtBottom')"
    :isLoadingTop="isLoadingNewLogs"
    :isLoadingBottom="isLoadingOldLogs"
  >
    <template #header>
      <TrafficConnectionsListSortHeader
        v-bind="{ isEndpointContext, defaultSortMetrics }"
        v-model:search="search"
      />
      <tr><th class="h-[5px] p-0 bg-black" colspan="100%"></th></tr>
    </template>

    <slot />
  </UxTableList>
</template>

Layer 4: Sort Header

Renders the column headers from the metrics array. The first column is a search input (with 500ms debounce), remaining columns are sorters.

<!-- list/SortHeader.vue -->
<template>
  <tr>
    <UxTableListSearch v-if="!isEndpointContext" width="25%">
      <input v-model="search" type="text" placeholder="Filter" />
    </UxTableListSearch>

    <UxTableListSorter
      v-for="metric in defaultSortMetrics"
      :key="metric.name"
      :sortMetric="metric.name"
      :width="metric.width"
      :disabled="true"
    />
  </tr>
</template>

<script setup>
// Debounced search pass-through (500ms)
const debouncedEmit = useDebounceFn((val) => {
  emit('update:search', val)
}, 500)

const search = computed({
  get: () => props.search,
  set: (val) => debouncedEmit(val),
})
</script>

Layer 5: Item Row

Each row is a UxTableListExpandRow. Default slot contains the cells. The #details slot renders when the row expands. Events bubble up for the container to track open count.

<!-- list/ItemUI.vue -->
<template>
  <UxTableListExpandRow
    @detailsOpen="$emit('detailsOpen')"
    @detailsClose="$emit('detailsClose')"
  >
    <td>{{ auditLog.endpointId }}</td>
    <td>{{ auditLog.sourceExe }}</td>
    <td>{{ auditLog.sourceProtocol }}</td>
    <td>{{ auditLog.sourceAddress }}</td>
    <td>{{ auditLog.destinationAddress }}</td>
    <td class="text-right">{{ timestampAsDisplay(auditLog.timestamp) }}</td>

    <template #details>
      <TrafficConnectionsListItemDetails
        :auditLog="auditLog"
        class="bg-white w-full shadow-lg rounded-12"
      />
    </template>
  </UxTableListExpandRow>
</template>

Layer 6: Item Details

Fully decoupled from table concerns. Receives the data object as a prop and renders a detail card. Can contain nested sub-tables (e.g., requests within a connection).

<!-- list/ItemDetails.vue — decoupled from table -->
<template>
  <div class="py-12 px-6 font-bold text-13">
    <div class="grid grid-cols-3 gap-12">
      <!-- Source -->
      <div>
        <div class="hairline text-20 pb-3">Source</div>
        <UxLabelText label="Address">{{ auditLog.sourceAddress }}</UxLabelText>
        <UxLabelText label="Port">{{ auditLog.sourcePort }}</UxLabelText>
      </div>

      <!-- Destination -->
      <div>
        <div class="hairline text-20 pb-3">Destination</div>
        <UxLabelText label="Address">{{ auditLog.destinationAddress }}</UxLabelText>
        <UxLabelText label="Port">{{ auditLog.destinationPort }}</UxLabelText>
      </div>

      <!-- Meta -->
      <div>
        <div class="hairline text-20 pb-3">Meta</div>
        <div class="flex gap-1 flex-wrap">
          <UxTag v-for="tag in auditLog.tags" :key="tag" :no-x="true">{{ tag }}</UxTag>
        </div>
      </div>
    </div>

    <!-- Nested sub-table -->
    <TrafficRequestsList :connectionId :compact="true" />
  </div>
</template>

Key Patterns

Header Separator

A 5px black bar separates the column headers from the data rows. Every table uses this.

<tr><th class="h-[5px] p-0 bg-black" colspan="100%"></th></tr>

Totals Separator

A 2px grey bar separates the totals row from the data rows.

<tr><th class="h-[2px] p-0 bg-grey-200" colspan="100%"></th></tr>

Sort Metrics Array

The column definition array drives both the header and sorting behavior. If a metric has a search callback, UxTableListColumnHeader renders a search input instead of a sorter.

const sortMetrics = ref([
  // search callback → renders UxTableListSearch
  { name: 'vendor', width: '200px', search: (needle) => { search.value = needle } },

  // no search → renders UxTableListSorter
  { name: 'requests', width: '120px' },

  // additional properties used by production tables:
  // { name: 'bytes',  width: '100px', total: 1048576, unit: 'bytes' },
  // { name: 'status', width: '80px',  disabled: true, align: 'center' },
])

Scroll Event Chain

UxTableList emits scrollAtTop, scrollingDown, and scrollAtBottom. The UI wrapper bubbles these to the container, which uses them for auto-refresh (at top) and infinite scroll (at bottom).

Sticky Header

The primary reason this table system was built: locking column headers to the top of the viewport while the user scrolls through data rows. UxTableListHeader wraps the <thead> with sticky top-0 positioning. Z-index adjusts by mode: z-[2] normally, z-[1] in compact mode, to prevent overlap with other sticky elements.

sticky positioning works relative to the nearest scrollable ancestor. In the product app, this is the #page-scroll-window element. The scroll event callbacks (scrollAtTop, scrollingDown, scrollAtBottom) are wired to this same element via document.getElementById('page-scroll-window'). If the table is in a different scroll container, the sticky header still works but the scroll event callbacks won't fire unless the element ID matches.

<thead class="sticky top-0" :class="compact ? 'z-[1]' : 'z-[2]'">

Open Item Tracking

The container keeps a totalOpenItems counter. Each @detailsOpen increments it, each @detailsClose decrements it. Auto-refresh is paused while any items are expanded, preventing jarring list updates while the user reads details.

Expanded Content Styling

The #details slot content uses bg-white shadow-lg rounded-12 for a card-like appearance within the grape-tinted expansion area.

Sub-Component Reference

Each sub-component has its own demo and API documentation.