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
| Prop | Type | Default | Required | Description |
|---|---|---|---|---|
column | string | - | ✅ Yes | Column identifier/name (used to track which column is sorted) |
currentSort | string | null | null | ✅ Yes | Currently sorted column name (or null if not sorted) |
currentDirection | 'asc' | 'desc' | null | null | ✅ Yes | Current 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.
// 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.
// If currentSort="name", only the name column's arrows show active
currentSort="name" // Active
currentSort="id" // Inactive
currentSort={null} // InactivecurrentDirection
The direction of the current sort. Only used when currentSort matches this component's column.
currentDirection="asc" // Ascending sort
currentDirection="desc" // Descending sort
currentDirection={null} // No sortmode
Indicates whether sorting is handled client-side or server-side. Informational only - doesn't affect component behavior.
mode="client" // Sorting handled by parent
mode="server" // API request sent by parentEvents
| Event | Payload | Description |
|---|---|---|
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:
{
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
<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)
<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)
<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:
<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:
<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:
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:
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
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
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
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
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
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:
<!-- 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:
/* 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
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+
Related Components
- DocBitsTable - Complete table with sorting built-in
- UnifiedTable - Alternative table with sorting
- RowSelectionCheckbox - Row selection
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