Skip to content

Tables Component

Version: 0.6.0
Last Updated: January 3, 2026
Location: src/templates/partials/tables.html

Tables display structured data in rows and columns. Arctyk tables integrate Bootstrap 5 styling, sorting capabilities, responsive design, and accessibility features.


Overview

Table features:

  • Bootstrap 5 responsive design
  • Sortable columns with visual indicators
  • Pagination support
  • Hover effects and row selection
  • Icon support with Bootstrap Icons
  • Status indicators and badges
  • Responsive table-scroll on mobile
  • Keyboard accessible
  • ARIA compliant

Basic Table

<table class="table table-hover">
  <thead class="table-light">
    <tr>
      <th>ID</th>
      <th>Title</th>
      <th>Status</th>
      <th>Created</th>
    </tr>
  </thead>
  <tbody>
    {% for ticket in tickets %}
    <tr>
      <td>#{{ ticket.ticket_number }}</td>
      <td>
        <a href="{% url 'tickets:ticket_detail' ticket.pk %}">
          {{ ticket.title }}
        </a>
      </td>
      <td>
        <span class="badge bg-{{ ticket.status_color }}">
          {{ ticket.get_status_display }}
        </span>
      </td>
      <td>{{ ticket.created_at|date:"M d, Y" }}</td>
    </tr>
    {% endfor %}
  </tbody>
</table>

Sortable Tables

With Sort Headers

{% load sort_tags %}

<table class="table table-hover">
    <thead class="table-light">
        <tr>
            <th>{% sort_header 'ticket_number' 'ID' %}</th>
            <th>{% sort_header 'title' 'Title' %}</th>
            <th>{% sort_header 'status' 'Status' %}</th>
            <th>{% sort_header 'created_at' 'Created' %}</th>
        </tr>
    </thead>
    <tbody>
        {% for ticket in tickets %}
        <tr>
            <td>#{{ ticket.ticket_number }}</td>
            <td>{{ ticket.title }}</td>
            <td>
                <span class="badge bg-{{ ticket.status_color }}">
                    {{ ticket.get_status_display }}
                </span>
            </td>
            <td>{{ ticket.created_at|date:"M d, Y" }}</td>
        </tr>
        {% endfor %}
    </tbody>
</table>

Styled Variants

Striped Rows

<table class="table table-striped table-hover">
  <!-- Content -->
</table>

Bordered Table

<table class="table table-bordered table-hover">
  <!-- Content -->
</table>

Small Table

<table class="table table-sm table-hover">
  <!-- Content -->
</table>

Row States

Clickable Rows

<tbody>
  {% for ticket in tickets %}
  <tr
    class="table-row"
    data-href="{% url 'tickets:ticket_detail' ticket.pk %}"
    tabindex="0"
    role="button"
  >
    <td>#{{ ticket.ticket_number }}</td>
    <td>{{ ticket.title }}</td>
    <td><span class="badge">{{ ticket.status }}</span></td>
  </tr>
  {% endfor %}
</tbody>

<script>
  document.querySelectorAll(".table-row").forEach((row) => {
    row.addEventListener("click", function () {
      window.location = this.dataset.href;
    });

    row.addEventListener("keypress", function (e) {
      if (e.key === "Enter") {
        window.location = this.dataset.href;
      }
    });
  });
</script>

Row Highlighting

<tbody>
  {% for ticket in tickets %}
  <tr
    class="{% if ticket.is_urgent %}table-danger{% elif ticket.is_overdue %}table-warning{% endif %}"
  >
    <td>{{ ticket.title }}</td>
  </tr>
  {% endfor %}
</tbody>

Complex Columns

Actions Column

<thead>
  <tr>
    <th>Title</th>
    <th>Status</th>
    <th class="text-end">Actions</th>
  </tr>
</thead>
<tbody>
  {% for ticket in tickets %}
  <tr>
    <td>{{ ticket.title }}</td>
    <td><span class="badge">{{ ticket.status }}</span></td>
    <td>
      <div class="btn-group btn-group-sm" role="group">
        <a
          href="{% url 'tickets:ticket_detail' ticket.pk %}"
          class="btn btn-outline-primary"
          title="View"
        >
          <i class="bi bi-eye"></i>
        </a>
        <a
          href="{% url 'tickets:ticket_update' ticket.pk %}"
          class="btn btn-outline-secondary"
          title="Edit"
        >
          <i class="bi bi-pencil"></i>
        </a>
      </div>
    </td>
  </tr>
  {% endfor %}
</tbody>

Status with Icon

<td>
  <i
    class="bi bi-{{ ticket.status_icon }}"
    class="me-2"
    title="{{ ticket.get_status_display }}"
  ></i>
  <span>{{ ticket.get_status_display }}</span>
</td>

Responsive Design

Responsive Wrapper

<div class="table-responsive">
  <table class="table">
    <!-- Table content -->
  </table>
</div>

Mobile Column Labels

<table class="table" data-mobile-labels>
  <thead class="d-none d-md-table-header-group">
    <tr>
      <th>Title</th>
      <th>Status</th>
      <th>Date</th>
    </tr>
  </thead>
  <tbody>
    {% for ticket in tickets %}
    <tr class="d-flex flex-column d-md-table-row">
      <td data-label="Title">{{ ticket.title }}</td>
      <td data-label="Status">{{ ticket.status }}</td>
      <td data-label="Date">{{ ticket.created_at }}</td>
    </tr>
    {% endfor %}
  </tbody>
</table>

Empty State

{% if not tickets %}
<div class="alert alert-info mt-4">
  <i class="bi bi-info-circle me-2"></i>
  <strong>No tickets found.</strong>
  <a href="{% url 'tickets:ticket_create' %}">Create one now</a>
</div>
{% else %}
<table class="table">
  <!-- Table content -->
</table>
{% endif %}

View Implementation

from django.views.generic import ListView
from tickets.models import Ticket

class TicketListView(ListView):
    model = Ticket
    template_name = 'tickets/ticket_list.html'
    context_object_name = 'tickets'
    paginate_by = 20

    def get_queryset(self):
        queryset = Ticket.objects.all().select_related('assigned_to')

        # Sorting
        sort = self.request.GET.get('sort', '-created_at')
        if sort in ['title', '-title', 'created_at', '-created_at']:
            queryset = queryset.order_by(sort)

        # Filtering
        status = self.request.GET.get('status')
        if status:
            queryset = queryset.filter(status=status)

        return queryset

    def get_context_data(self, **kwargs):
        context = super().get_context_data(**kwargs)
        context['sort'] = self.request.GET.get('sort')
        context['status_filter'] = self.request.GET.get('status')
        return context

Accessibility

ARIA Attributes

<table class="table" role="grid" aria-label="Ticket list">
  <thead role="rowgroup">
    <tr role="row">
      <th role="columnheader">Title</th>
      <th role="columnheader">Status</th>
      <th role="columnheader">Date</th>
    </tr>
  </thead>
  <tbody role="rowgroup">
    {% for ticket in tickets %}
    <tr role="row">
      <td role="gridcell">{{ ticket.title }}</td>
      <td role="gridcell">{{ ticket.status }}</td>
      <td role="gridcell">{{ ticket.created_at }}</td>
    </tr>
    {% endfor %}
  </tbody>
</table>

Caption

<table class="table">
  <caption>
    List of support tickets
  </caption>
  <thead>
    <tr>
      <th>Title</th>
      <th>Status</th>
    </tr>
  </thead>
</table>

Testing

Table Rendering Tests

from django.test import TestCase, Client
from tickets.models import Ticket

class TicketTableTest(TestCase):
    @classmethod
    def setUpTestData(cls):
        for i in range(5):
            Ticket.objects.create(title=f'Ticket {i}')

    def test_table_renders(self):
        response = self.client.get('/tickets/')
        self.assertEqual(response.status_code, 200)
        self.assertContains(response, '<table')

    def test_table_rows_count(self):
        response = self.client.get('/tickets/')
        self.assertEqual(response.context['tickets'].count(), 5)

    def test_table_sorting(self):
        response = self.client.get('/tickets/?sort=-title')
        tickets = list(response.context['tickets'])
        self.assertEqual(tickets[0].title, 'Ticket 4')

    def test_empty_state(self):
        Ticket.objects.all().delete()
        response = self.client.get('/tickets/')
        self.assertContains(response, 'No tickets found')

CSS Customization

// Custom table styling
.table {
  font-size: 0.95rem;

  thead {
    background-color: #f8f9fa;
    font-weight: 600;
  }

  tbody tr {
    transition: background-color 0.2s ease;

    &:hover {
      background-color: #f8f9fa;
    }
  }

  td {
    vertical-align: middle;
    padding: 1rem 0.75rem;
  }

  .badge {
    white-space: nowrap;
  }
}

// Responsive
@media (max-width: 768px) {
  .table {
    font-size: 0.85rem;

    td {
      padding: 0.75rem 0.5rem;
    }
  }
}

Best Practices

✅ Use semantic HTML (<table>, <thead>, <tbody>, <th>)
✅ Always include column headers
✅ Use responsive wrapper for mobile
✅ Show empty state when no data
✅ Include sorting/filtering capabilities
✅ Use badges for status indicators
✅ Make rows clickable with proper keyboard support
✅ Include pagination for large datasets
❌ Don't use tables for layout
❌ Don't nest tables
❌ Don't truncate important data without tooltips
❌ Don't use horizontal scrolling on mobile


Resources