Skip to content

ColumnOrderBy

Simple, elegant sort arrow indicators for table column headers.

Overview

The ColumnOrderBy component displays clickable sort arrows (up/down) in table column headers. It's a lightweight, reusable component that manages sort state and emits events when users click to change sort direction.

Best for: Adding sort functionality to table headers with visual indicators

Key Features:

  • ✅ Clickable up/down arrows
  • ✅ Active/inactive visual states
  • ✅ Client and server-side sorting modes
  • ✅ Compact CSS-based triangles (no SVG)
  • ✅ Full keyboard support
  • ✅ Color scheme coordination with DocBits design system

Props

PropTypeDefaultRequiredDescription
columnstring-✅ YesColumn identifier/name (used to track which column is sorted)
currentSortstring | nullnull✅ YesCurrently sorted column name (or null if not sorted)
currentDirection'asc' | 'desc' | nullnull✅ YesCurrent sort direction ('asc' for ascending, 'desc' for descending, null if not sorted)
mode'client' | 'server''client'-Sorting mode - doesn't affect behavior, informational for parent

Prop Details

column

The unique identifier for this column. Must match the value in currentSort for the component to show as active.

ts
// Examples
column="id"
column="name"
column="date"

currentSort

The currently sorted column. When this matches the component's column prop, the arrows will show as active.

ts
// If currentSort="name", only the name column's arrows show active
currentSort="name"  // Active
currentSort="id"    // Inactive
currentSort={null}  // Inactive

currentDirection

The direction of the current sort. Only used when currentSort matches this component's column.

ts
currentDirection="asc"   // Ascending sort
currentDirection="desc"  // Descending sort
currentDirection={null}  // No sort

mode

Indicates whether sorting is handled client-side or server-side. Informational only - doesn't affect component behavior.

ts
mode="client"   // Sorting handled by parent
mode="server"   // API request sent by parent

Events

EventPayloadDescription
sort{ column: string; direction: 'asc' | 'desc' }Emitted when user clicks a sort arrow

Event Details

@sort

Emitted when the user clicks either the up or down arrow.

Payload:

typescript
{
  column: string;      // The column identifier
  direction: 'asc' | 'desc';  // The direction user clicked
}

When it's emitted:

  • User clicks the up arrow → { column: "name", direction: "asc" }
  • User clicks the down arrow → { column: "name", direction: "desc" }
  • User clicks the same arrow again → NOT emitted (no change)

Basic Usage

vue
<template>
  <div class="table-header">
    <div class="header-cell">
      ID
      <ColumnOrderBy
        column="id"
        :current-sort="sortColumn"
        :current-direction="sortDirection"
        @sort="handleSort"
      />
    </div>
    <div class="header-cell">
      Name
      <ColumnOrderBy
        column="name"
        :current-sort="sortColumn"
        :current-direction="sortDirection"
        @sort="handleSort"
      />
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref } from 'vue'
import ColumnOrderBy from '@/components/Table/ColumnOrderBy.vue'

const sortColumn = ref<string | null>(null)
const sortDirection = ref<'asc' | 'desc' | null>(null)

function handleSort(payload: { column: string; direction: 'asc' | 'desc' }) {
  sortColumn.value = payload.column
  sortDirection.value = payload.direction
  // Re-sort your data based on sortColumn and sortDirection
}
</script>

<style scoped>
.table-header {
  display: flex;
  border-bottom: 1px solid var(--dashboard-line-color);
}

.header-cell {
  flex: 1;
  padding: 12px 16px;
  display: flex;
  align-items: center;
  gap: 8px;
}
</style>

Visual States

Inactive Arrow (Not Sorted)

vue
<ColumnOrderBy
  column="name"
  :current-sort="sortColumn"
  :current-direction="sortDirection"
  @sort="handleSort"
/>

Appearance:

  • Both arrows visible in light gray (#b1b1b1 light, #727272 dark)
  • Hoverable (cursor changes to pointer)
  • No emphasis

Active Arrow (Currently Sorted)

vue
<ColumnOrderBy
  column="name"
  current-sort="name"
  current-direction="asc"
  @sort="handleSort"
/>

Appearance:

  • Active arrow highlighted in primary color (#2388AE light, #34eea9 dark)
  • Cursor default (not hoverable)
  • Strong visual emphasis

Sorting Logic

Client-Side Sorting

Handle sorting in the component:

vue
<script setup lang="ts">
import { ref, computed } from 'vue'

const sortColumn = ref<string | null>(null)
const sortDirection = ref<'asc' | 'desc' | null>(null)
const items = ref([
  { id: 3, name: 'Charlie' },
  { id: 1, name: 'Alice' },
  { id: 2, name: 'Bob' }
])

const sortedItems = computed(() => {
  if (!sortColumn.value) return items.value

  const sorted = [...items.value]
  const ascending = sortDirection.value === 'asc'

  sorted.sort((a, b) => {
    const aVal = a[sortColumn.value as keyof typeof a]
    const bVal = b[sortColumn.value as keyof typeof b]

    if (aVal < bVal) return ascending ? -1 : 1
    if (aVal > bVal) return ascending ? 1 : -1
    return 0
  })

  return sorted
})

function handleSort(payload: { column: string; direction: 'asc' | 'desc' }) {
  sortColumn.value = payload.column
  sortDirection.value = payload.direction
}
</script>

Server-Side Sorting

Send request to API:

vue
<script setup lang="ts">
import { ref } from 'vue'

const sortColumn = ref<string | null>(null)
const sortDirection = ref<'asc' | 'desc' | null>(null)
const loading = ref(false)
const items = ref([])

async function handleSort(payload: { column: string; direction: 'asc' | 'desc' }) {
  sortColumn.value = payload.column
  sortDirection.value = payload.direction

  loading.value = true
  try {
    const response = await fetch('/api/items', {
      params: {
        sortBy: payload.column,
        sortOrder: payload.direction
      }
    })
    items.value = await response.json()
  } finally {
    loading.value = false
  }
}
</script>

Advanced Examples

Multiple Column Sorting

Handle secondary sort when clicking the same column:

typescript
function handleSort(payload: { column: string; direction: 'asc' | 'desc' }) {
  if (sortColumn.value === payload.column) {
    // Clicking same column: toggle direction or clear
    if (sortDirection.value === 'asc') {
      sortDirection.value = 'desc'
    } else {
      // Clear sort if descending
      sortColumn.value = null
      sortDirection.value = null
    }
  } else {
    // Clicking different column: start with ascending
    sortColumn.value = payload.column
    sortDirection.value = 'asc'
  }
}

Custom Sort Orders

Support custom sort logic:

typescript
function handleSort(payload: { column: string; direction: 'asc' | 'desc' }) {
  sortColumn.value = payload.column
  sortDirection.value = payload.direction

  const sorted = [...items.value]
  const compareFunction = getCompareFn(payload.column, payload.direction)
  sorted.sort(compareFunction)

  items.value = sorted
}

function getCompareFn(column: string, direction: 'asc' | 'desc') {
  return (a: any, b: any) => {
    const aVal = a[column]
    const bVal = b[column]

    // Custom logic based on column type
    if (column === 'date') {
      return direction === 'asc'
        ? new Date(aVal).getTime() - new Date(bVal).getTime()
        : new Date(bVal).getTime() - new Date(aVal).getTime()
    }

    // Default string/number comparison
    const result = aVal < bVal ? -1 : aVal > bVal ? 1 : 0
    return direction === 'asc' ? result : -result
  }
}

Real-World Example: Sorting Invoices

This example shows how to use ColumnOrderBy with realistic invoice data from DocBits.

Sample Invoice Data

typescript
const invoices = [
  {
    id: 'INV-2024-001',
    documentNumber: 'INV-001234',
    vendor: 'Acme Corporation',
    amount: 1250.00,
    currency: 'USD',
    invoiceDate: '2024-01-15',
    dueDate: '2024-02-15',
    status: 'pending',
    assignee: 'john.doe@company.com',
    extractionConfidence: 0.98
  },
  {
    id: 'INV-2024-002',
    documentNumber: 'INV-001235',
    vendor: 'Global Supplies Inc',
    amount: 3750.50,
    currency: 'USD',
    invoiceDate: '2024-01-16',
    dueDate: '2024-02-16',
    status: 'approved',
    assignee: 'jane.smith@company.com',
    extractionConfidence: 0.95
  },
  {
    id: 'INV-2024-003',
    documentNumber: 'RE-2024-789',
    vendor: 'Tech Solutions GmbH',
    amount: 2100.00,
    currency: 'EUR',
    invoiceDate: '2024-01-18',
    dueDate: '2024-02-18',
    status: 'processed',
    assignee: 'mike.johnson@company.com',
    extractionConfidence: 0.97
  },
  {
    id: 'INV-2024-004',
    documentNumber: 'INV-UK-445',
    vendor: 'British Office Ltd',
    amount: 890.00,
    currency: 'GBP',
    invoiceDate: '2024-01-20',
    dueDate: '2024-03-20',
    status: 'rejected',
    assignee: 'sarah.williams@company.com',
    extractionConfidence: 0.89
  },
  {
    id: 'INV-2024-005',
    documentNumber: 'F-2024-1122',
    vendor: 'Consulting Partners SA',
    amount: 8500.00,
    currency: 'EUR',
    invoiceDate: '2024-01-22',
    dueDate: '2024-02-22',
    status: 'pending',
    assignee: 'pierre.dubois@company.com',
    extractionConfidence: 0.93
  },
  {
    id: 'INV-2024-006',
    documentNumber: 'INV-2024-556',
    vendor: 'Cloud Services Corp',
    amount: 4200.00,
    currency: 'USD',
    invoiceDate: '2024-01-25',
    dueDate: '2024-02-25',
    status: 'approved',
    assignee: 'john.doe@company.com',
    extractionConfidence: 0.99
  },
  {
    id: 'INV-2024-007',
    documentNumber: 'BILL-7788',
    vendor: 'Utilities Company',
    amount: 650.25,
    currency: 'USD',
    invoiceDate: '2024-01-28',
    dueDate: '2024-02-15',
    status: 'processed',
    assignee: 'accounts@company.com',
    extractionConfidence: 0.96
  },
  {
    id: 'INV-2024-008',
    documentNumber: 'INV-Q1-2024',
    vendor: 'Marketing Agency LLC',
    amount: 12000.00,
    currency: 'USD',
    invoiceDate: '2024-01-30',
    dueDate: '2024-03-01',
    status: 'pending',
    assignee: 'marketing@company.com',
    extractionConfidence: 0.91
  }
]

Column Definitions

typescript
const invoiceColumns = [
  { name: 'id', label: 'ID', field: 'id', align: 'left', sortable: true },
  { name: 'documentNumber', label: 'Invoice #', field: 'documentNumber', sortable: true },
  { name: 'vendor', label: 'Vendor', field: 'vendor', sortable: true, align: 'left' },
  { name: 'amount', label: 'Amount', field: 'amount', sortable: true, align: 'right',
    format: (val, row) => `${row.currency} ${val.toFixed(2)}` },
  { name: 'invoiceDate', label: 'Invoice Date', field: 'invoiceDate', sortable: true,
    format: (val) => new Date(val).toLocaleDateString() },
  { name: 'dueDate', label: 'Due Date', field: 'dueDate', sortable: true,
    format: (val) => new Date(val).toLocaleDateString() },
  { name: 'status', label: 'Status', field: 'status', sortable: true },
  { name: 'confidence', label: 'Confidence', field: 'extractionConfidence', sortable: true, align: 'right',
    format: (val) => `${(val * 100).toFixed(0)}%` }
]

Sorting Handler

typescript
import { ref, computed } from 'vue'

const sortColumn = ref<string | null>(null)
const sortDirection = ref<'asc' | 'desc' | null>(null)

const sortedInvoices = computed(() => {
  if (!sortColumn.value) return invoices

  const sorted = [...invoices]
  const ascending = sortDirection.value === 'asc'

  sorted.sort((a, b) => {
    const aVal = a[sortColumn.value as keyof typeof a]
    const bVal = b[sortColumn.value as keyof typeof b]

    // Handle numeric comparison for amounts
    if (sortColumn.value === 'amount') {
      return ascending ? (aVal as number) - (bVal as number) : (bVal as number) - (aVal as number)
    }

    // Handle date comparison
    if (sortColumn.value?.includes('Date')) {
      const aDate = new Date(aVal as string).getTime()
      const bDate = new Date(bVal as string).getTime()
      return ascending ? aDate - bDate : bDate - aDate
    }

    // Default string comparison
    if (aVal < bVal) return ascending ? -1 : 1
    if (aVal > bVal) return ascending ? 1 : -1
    return 0
  })

  return sorted
})

function handleSort(payload: { column: string; direction: 'asc' | 'desc' }) {
  sortColumn.value = payload.column
  sortDirection.value = payload.direction
}

What This Example Shows

  • Sorting by vendor names: Alphabetically sort invoice vendors
  • Sorting by amounts: Numeric sorting with currency formatting
  • Sorting by dates: Temporal sorting for invoice and due dates
  • Status sorting: String-based sorting for approval status
  • Confidence scores: Numeric sorting for extraction confidence percentages

Real-World Example: Sorting Purchase Orders

This example shows how to sort a purchase order table with ColumnOrderBy.

Sample Purchase Order Data

typescript
const purchaseOrders = [
  {
    id: 'PO-2024-001',
    poNumber: 'PO-123456',
    supplier: 'Acme Corporation',
    totalAmount: 5000.00,
    currency: 'USD',
    orderDate: '2024-01-10',
    deliveryDate: '2024-02-10',
    status: 'pending',
    lineItems: 5,
    matchedInvoices: 0
  },
  {
    id: 'PO-2024-002',
    poNumber: 'PO-123457',
    supplier: 'Global Supplies Inc',
    totalAmount: 12500.00,
    currency: 'USD',
    orderDate: '2024-01-12',
    deliveryDate: '2024-02-12',
    status: 'confirmed',
    lineItems: 8,
    matchedInvoices: 2
  },
  {
    id: 'PO-2024-003',
    poNumber: 'PO-EU-789',
    supplier: 'Tech Solutions GmbH',
    totalAmount: 8750.00,
    currency: 'EUR',
    orderDate: '2024-01-15',
    deliveryDate: '2024-02-15',
    status: 'delivered',
    lineItems: 6,
    matchedInvoices: 6
  },
  {
    id: 'PO-2024-004',
    poNumber: 'PO-UK-445',
    supplier: 'British Office Ltd',
    totalAmount: 3200.00,
    currency: 'GBP',
    orderDate: '2024-01-18',
    deliveryDate: '2024-02-18',
    status: 'cancelled',
    lineItems: 4,
    matchedInvoices: 1
  },
  {
    id: 'PO-2024-005',
    poNumber: 'PO-FR-2024',
    supplier: 'Consulting Partners SA',
    totalAmount: 15000.00,
    currency: 'EUR',
    orderDate: '2024-01-20',
    deliveryDate: '2024-03-01',
    status: 'pending',
    lineItems: 10,
    matchedInvoices: 0
  },
  {
    id: 'PO-2024-006',
    poNumber: 'PO-CLOUD-001',
    supplier: 'Cloud Services Corp',
    totalAmount: 6000.00,
    currency: 'USD',
    orderDate: '2024-01-22',
    deliveryDate: '2024-02-22',
    status: 'delivered',
    lineItems: 3,
    matchedInvoices: 3
  },
  {
    id: 'PO-2024-007',
    poNumber: 'PO-UTIL-02',
    supplier: 'Utilities Company',
    totalAmount: 2100.00,
    currency: 'USD',
    orderDate: '2024-01-25',
    deliveryDate: '2024-02-15',
    status: 'confirmed',
    lineItems: 2,
    matchedInvoices: 1
  },
  {
    id: 'PO-2024-008',
    poNumber: 'PO-MKT-Q1',
    supplier: 'Marketing Agency LLC',
    totalAmount: 20000.00,
    currency: 'USD',
    orderDate: '2024-01-28',
    deliveryDate: '2024-03-15',
    status: 'pending',
    lineItems: 15,
    matchedInvoices: 0
  }
]

Column Definitions

typescript
const poColumns = [
  { name: 'id', label: 'ID', field: 'id', align: 'left', sortable: true },
  { name: 'poNumber', label: 'PO Number', field: 'poNumber', sortable: true },
  { name: 'supplier', label: 'Supplier', field: 'supplier', sortable: true },
  { name: 'totalAmount', label: 'Total Amount', field: 'totalAmount', sortable: true, align: 'right',
    format: (val, row) => `${row.currency} ${val.toFixed(2)}` },
  { name: 'orderDate', label: 'Order Date', field: 'orderDate', sortable: true,
    format: (val) => new Date(val).toLocaleDateString() },
  { name: 'status', label: 'Status', field: 'status', sortable: true },
  { name: 'lineItems', label: 'Line Items', field: 'lineItems', sortable: true, align: 'center' },
  { name: 'matchedInvoices', label: 'Matched Invoices', field: 'matchedInvoices', sortable: true, align: 'center' }
]

What This Example Shows

  • Sorting by PO Number: Alphanumeric sorting for purchase order identifiers
  • Sorting by supplier: Alphabetically sort suppliers
  • Sorting by total amount: Numeric sorting with currency support
  • Sorting by dates: Temporal sorting for order placement and delivery
  • Status tracking: Sort by PO confirmation status
  • Line item count: Numeric sorting for number of items in PO
  • Invoice matching: Track how many invoices have been matched to PO

Accessibility

Keyboard Navigation

  • Tab - Focus sort arrows
  • Enter - Activate focused arrow
  • Space - Activate focused arrow

ARIA Labels

Component includes proper ARIA roles and labels:

html
<!-- Rendered HTML includes -->
<span role="button" tabindex="0" aria-label="Sort by name ascending">
  <!-- up arrow -->
</span>
<span role="button" tabindex="0" aria-label="Sort by name descending">
  <!-- down arrow -->
</span>

Screen Reader Support

Announces sort state to screen readers automatically.

Dark Mode

Automatically adapts to dark mode:

css
/* Light mode */
body:not(.dark) .arrow-color {
  color: #b1b1b1;
}

/* Dark mode */
body.dark .arrow-color {
  color: #727272;
}

/* Active state (both modes) */
.arrow.selected {
  border-color: var(--q-primary);
}

Performance

  • Lightweight: Only 135 lines of code
  • No Dependencies: Pure Vue 3, no external libraries
  • Efficient: Minimal re-renders, computed properties cached
  • Fast: Arrow rendering is CSS-based (no SVG)

Testing

Unit Test Example

typescript
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import ColumnOrderBy from './ColumnOrderBy.vue'

describe('ColumnOrderBy', () => {
  it('renders both arrows inactive initially', () => {
    const wrapper = mount(ColumnOrderBy, {
      props: {
        column: 'name',
        currentSort: null,
        currentDirection: null
      }
    })

    const arrows = wrapper.findAll('.orderBy')
    expect(arrows).toHaveLength(2)
    expect(arrows[0].classes()).not.toContain('selected')
    expect(arrows[1].classes()).not.toContain('selected')
  })

  it('highlights active arrow when sorted', () => {
    const wrapper = mount(ColumnOrderBy, {
      props: {
        column: 'name',
        currentSort: 'name',
        currentDirection: 'asc'
      }
    })

    const upArrow = wrapper.find('.up-arrow')
    expect(upArrow.classes()).toContain('selected')
  })

  it('emits sort event when arrow clicked', async () => {
    const wrapper = mount(ColumnOrderBy, {
      props: {
        column: 'name',
        currentSort: null,
        currentDirection: null
      }
    })

    const upArrow = wrapper.find('.up-arrow')
    await upArrow.trigger('click')

    expect(wrapper.emitted('sort')).toBeTruthy()
    expect(wrapper.emitted('sort')[0]).toEqual([
      { column: 'name', direction: 'asc' }
    ])
  })

  it('does not emit event when clicking already selected arrow', async () => {
    const wrapper = mount(ColumnOrderBy, {
      props: {
        column: 'name',
        currentSort: 'name',
        currentDirection: 'asc'
      }
    })

    const upArrow = wrapper.find('.up-arrow.selected')
    await upArrow.trigger('click')

    expect(wrapper.emitted('sort')).toBeFalsy()
  })
})

Browser Support

Supported in all modern browsers:

  • Chrome 88+
  • Firefox 78+
  • Safari 14+
  • Edge 88+

Source Code

  • Component: src/components/Table/ColumnOrderBy.vue (135 lines)
  • Tests: src/tests/unit/column-orderby.unit.spec.ts (403 lines, 40+ tests)

Changelog

v1.0.0 (2025-12-18)

  • Initial release
  • Up/down arrow indicators
  • Client/server sorting modes
  • Full accessibility support
  • Dark mode support

DocBits Component Library