Skip to main content
brickwork
GitHub Log in Get started
Display
Theme
Density
Direction
Brand

Documentation

Data table

A structural data table with optional selection, sorting, empty state, and responsive modes.

All components Component Brickwork 4.3.1

Try it

Selectable orders

Open full-page preview

Rows in a named table form.

Order Status
#1024 Open
#1025 Paid
Use for record lists whose row identity and selection contract are owned by the consumer.

Use it

Working invocation

The live demonstration uses this site's routes. For a portable implementation, download the complete Django and Brickwork cookbook: every file below is shown exactly as it ships in the archive.

Download the consumer cookbook

component_cookbook/views.py

Python

"""Deterministic interaction endpoints: no models, storage, or external calls."""
from __future__ import annotations

from django.contrib import messages
from django.core.paginator import Paginator
from django.http import HttpRequest, HttpResponse, HttpResponseBadRequest
from django.shortcuts import redirect, render
from django.urls import reverse
from django.utils.cache import patch_vary_headers
from django.utils.safestring import mark_safe
from django.utils.translation import gettext as _
from django.views.decorators.http import require_http_methods, require_POST

from .forms import FilterForm, LanguageForm, MessageForm, UploadForm

LANGUAGES = (("python", "Python"), ("django", "Django"), ("htmx", "HTMX"), ("alpine", "Alpine"))
ROWS = tuple({"name": name, "status": status} for name, status in (
    ("Modal response", "ready"), ("Combobox endpoint", "draft"), ("Toast delivery", "ready"),
    ("No-JavaScript floor", "draft"), ("Slide-over focus", "ready"), ("Tooltip focus", "draft"),
    ("Dropdown links", "ready"), ("Native disclosure", "draft"), ("Tag input carrier", "ready"),
    ("Dropzone metadata", "draft"), ("Table selection", "ready"), ("Search request", "draft"),
))


def _htmx(request: HttpRequest) -> bool:
    return request.headers.get("HX-Request") == "true"


def _branch(request: HttpRequest, partial: str, page: str, context: dict, status: int = 200) -> HttpResponse:
    response = render(request, partial if _htmx(request) else page, context, status=status)
    patch_vary_headers(response, ("HX-Request",))
    return response


def _page(request: HttpRequest, template: str, **context) -> HttpResponse:
    return render(request, template, context)


def index(request): return _page(request, "component_cookbook/index.html")
def dropdown(request):
    return _page(request, "component_cookbook/dropdown.html", items=[
        {"label": _("Open cookbook"), "url": reverse("component_cookbook:index"), "icon": "external-link"},
        {"divider": True}, {"label": _("Delete draft"), "url": "#delete", "variant": "danger", "icon": "trash"},
    ])
def tabs(request):
    active = request.GET.get("tab", "overview")
    if active not in {"overview", "api"}:
        return HttpResponseBadRequest("Unknown cookbook tab.")
    return _page(request, "component_cookbook/tabs.html", active=active, tabs=[{"key": "overview", "label": _("Overview")}, {"key": "api", "label": "API"}], overview=mark_safe("<p>Overview content comes from this consumer template.</p>"), api=mark_safe("<p>The API panel uses the same package tab relationship.</p>"))
def tooltip(request): return _page(request, "component_cookbook/tooltip.html", tooltip_id="cookbook-tooltip", text=_("Explains the button"), placement="top")
def disclosure(request): return _page(request, "component_cookbook/disclosure.html")
def tag_input(request): return _page(request, "component_cookbook/tag_input.html")


def modal(request):
    return _branch(request, "component_cookbook/partials/modal.html", "component_cookbook/modal.html", {"form": MessageForm(), "modal_id": "cookbook-message-modal", "close_href": reverse("component_cookbook:index")})


@require_POST
def modal_submit(request):
    form = MessageForm(request.POST)
    context = {"form": form, "modal_id": "cookbook-message-modal", "close_href": reverse("component_cookbook:index")}
    if not form.is_valid():
        return _branch(request, "component_cookbook/partials/modal.html", "component_cookbook/modal.html", context, 422 if _htmx(request) else 200)
    if _htmx(request):
        response = HttpResponse(status=204)
        response["HX-Trigger"] = '{"bw:modal:close": {"id": "cookbook-message-modal"}}'
        return response
    messages.success(request, _("The demo accepted your message without saving it."))
    return redirect("component_cookbook:modal")


def slide_over(request):
    size = request.GET.get("size", "md")
    placement = request.GET.get("placement", "end")
    if size not in {"sm", "md", "lg"} or placement not in {"start", "end"}:
        return HttpResponseBadRequest("Invalid slide-over options.")
    return _branch(request, "component_cookbook/partials/slide_over.html", "component_cookbook/slide_over.html", {"close_href": reverse("component_cookbook:index"), "size": size, "placement": placement})


@require_POST
def toggle(request):
    return _branch(request, "component_cookbook/partials/toggle_outcome.html", "component_cookbook/toggle.html", {"enabled": request.POST.get("notices") == "on"})


@require_POST
def toast(request):
    if not _htmx(request):
        messages.success(request, _("The server delivered this demonstration outcome."))
        response = redirect("component_cookbook:toast-page")
    else:
        response = render(request, "component_cookbook/partials/toast_oob.html")
    patch_vary_headers(response, ("HX-Request",))
    return response


def toast_page(request):
    return _page(request, "component_cookbook/toast_region.html")


def toast_region(request):
    return _page(request, "component_cookbook/toast_region.html")


def combobox(request): return _page(request, "component_cookbook/combobox.html", form=LanguageForm(), options_url=reverse("component_cookbook:combobox-options"))


def combobox_options(request):
    query = request.GET.get("q", "").strip().casefold()[:40]
    return render(request, "component_cookbook/partials/combobox_options.html", {"choices": [choice for choice in LANGUAGES if not query or query in choice[1].casefold()]})


@require_POST
def combobox_submit(request):
    form = LanguageForm(request.POST)
    context = {"form": form, "options_url": reverse("component_cookbook:combobox-options")}
    if form.is_valid():
        context["selected_label"] = dict(LANGUAGES)[form.cleaned_data["language"]]
    return _branch(request, "component_cookbook/partials/combobox_form.html", "component_cookbook/combobox.html", context, 422 if _htmx(request) and not form.is_valid() else 200)


def _rows(query: str, status: str):
    return [row for row in ROWS if (not query or query.casefold() in row["name"].casefold()) and (not status or row["status"] == status)]


def table(request):
    form = FilterForm(request.GET)
    query, status = (form.cleaned_data["q"], form.cleaned_data["status"]) if form.is_valid() else ("", "")
    context = {"filter_form": form, "page_obj": Paginator(_rows(query, status), 4).get_page(request.GET.get("page", 1)), "table_url": reverse("component_cookbook:table"), "form_invalid": not form.is_valid()}
    return _branch(request, "component_cookbook/partials/table_region.html", "component_cookbook/table.html", context)


def search(request):
    query = request.GET.get("q", "").strip()[:40]
    return _page(request, "component_cookbook/search.html", query=query, result_count=len(_rows(query, "")), search_url=reverse("component_cookbook:search"))


@require_http_methods(["GET", "POST"])
def upload(request):
    if request.method == "GET":
        return _page(request, "component_cookbook/upload.html", form=UploadForm())
    form = UploadForm(request.POST, request.FILES)
    context = {"form": form}
    if form.is_valid():
        uploaded = form.cleaned_data["file"]
        context["outcome"] = _("Validated %(name)s (%(size)s bytes). The application did not retain it.") % {"name": uploaded.name, "size": uploaded.size}
        context["form"] = UploadForm()
    return _branch(request, "component_cookbook/partials/upload_form.html", "component_cookbook/upload.html", context, 422 if _htmx(request) and not form.is_valid() else 200)

component_cookbook/forms.py

Python

from django import forms
from django.core.exceptions import ValidationError
from django.utils.translation import gettext_lazy as _


class MessageForm(forms.Form):
    message = forms.CharField(max_length=80, help_text=_("Up to 80 characters."))


class LanguageForm(forms.Form):
    language = forms.ChoiceField(choices=(("", _("Choose a language")), ("python", "Python"), ("django", "Django"), ("htmx", "HTMX")))


class FilterForm(forms.Form):
    q = forms.CharField(label=_("Filter examples"), max_length=40, required=False)
    status = forms.ChoiceField(required=False, choices=(("", _("All statuses")), ("ready", _("Ready")), ("draft", _("Draft"))))


class UploadForm(forms.Form):
    file = forms.FileField()

    def clean_file(self):
        uploaded = self.cleaned_data["file"]
        if uploaded.size > 256 * 1024:
            raise ValidationError(_("Choose a file smaller than 256 KB."))
        if uploaded.content_type not in {"text/plain", "application/pdf"}:
            raise ValidationError(_("Choose a plain text file or PDF."))
        return uploaded

component_cookbook/urls.py

Python

from django.urls import path

from . import views

app_name = "component_cookbook"

urlpatterns = [
    path("", views.index, name="index"),
    path("dropdown/", views.dropdown, name="dropdown"),
    path("tabs/", views.tabs, name="tabs"),
    path("tooltip/", views.tooltip, name="tooltip"),
    path("disclosure/", views.disclosure, name="disclosure"),
    path("tag-input/", views.tag_input, name="tag-input"),
    path("modal/", views.modal, name="modal"),
    path("modal/submit/", views.modal_submit, name="modal-submit"),
    path("slide-over/", views.slide_over, name="slide-over"),
    path("toggle/", views.toggle, name="toggle"),
    path("toasts/", views.toast_page, name="toast-page"),
    path("toast-region/", views.toast_region, name="toast-region"),
    path("toast/", views.toast, name="toast"),
    path("combobox/", views.combobox, name="combobox"),
    path("combobox/options/", views.combobox_options, name="combobox-options"),
    path("combobox/submit/", views.combobox_submit, name="combobox-submit"),
    path("table/", views.table, name="table"),
    path("search/", views.search, name="search"),
    path("upload/", views.upload, name="upload"),
]

templates/component_cookbook/partials/table_region.html

Django template

{% load brickwork_components i18n %}<section id="cookbook-table-region">{% include "brickwork/components/_filter_bar.html" with fields=filter_form action=table_url hx_get=table_url hx_target="#cookbook-table-region" clear_href=table_url %}{% if form_invalid %}<p role="alert">{% translate "Choose a supported status, then try again." %}</p>{% endif %}<table class="bw-data-table"><thead><tr><th scope="col">{% translate "Example" %}</th><th scope="col">{% translate "Status" %}</th></tr></thead><tbody>{% for row in page_obj %}<tr><td>{{ row.name }}</td><td>{{ row.status }}</td></tr>{% empty %}<tr><td colspan="2">{% translate "No example matches this filter." %}</td></tr>{% endfor %}</tbody></table>{% include "brickwork/components/_pagination.html" with page_obj=page_obj %}</section>

templates/component_cookbook/table.html

Django template

{% extends "component_cookbook/base.html" %}{% load i18n %}{% block heading %}{% translate "Data table, filter bar and pagination" %}{% endblock %}{% block introduction %}{% translate "GET parameters remain usable without JavaScript; HTMX replaces only the table region." %}{% endblock %}{% block cookbook_content %}{% include "component_cookbook/partials/table_region.html" %}{% endblock %}

templates/component_cookbook/base.html

Django template

{% extends "brickwork/shell/centred.html" %}
{% load static i18n %}
{% block page_title %}{% translate "Brickwork component cookbook" %}{% endblock %}
{% block head_js %}<script>window.BRICKWORK_JS_URL = "{% static 'brickwork/dist/brickwork.js' %}";</script>{% endblock %}
{% block body_js %}
<script src="{% static 'component_cookbook/vendor/htmx.min.js' %}"></script>
<script type="module">
import Alpine from "{% static 'component_cookbook/vendor/alpine.module.js' %}";
import focus from "{% static 'component_cookbook/vendor/focus.module.js' %}";
window.htmx.config.responseHandling = [{code: "204", swap: false}, {code: "422", swap: true}, {code: "[23]..", swap: true}, {code: "[45]..", swap: false, error: true}];
Alpine.plugin(focus);
const { registerBrickworkComponents } = await import(window.BRICKWORK_JS_URL);
registerBrickworkComponents(Alpine);
window.Alpine = Alpine;
Alpine.start();
</script>
{% endblock %}
{% block content %}
<header class="bw-stack bw-stack--sm"><p><a href="{% url 'component_cookbook:index' %}">{% translate "Component cookbook" %}</a></p><h1>{% block heading %}{% endblock %}</h1><p>{% block introduction %}{% endblock %}</p></header>
{% if messages %}{% for message in messages %}<p class="bw-alert bw-alert--success" role="status">{{ message }}</p>{% endfor %}{% endif %}
{% block cookbook_content %}{% endblock %}
{% endblock %}

templates/your_app/component.html

Django template

{% include "brickwork/components/_data_table.html" with table_id="orders" columns=columns rows=rows selectable=selectable responsive=responsive %}

your_app/views.py

Python

context = {'columns': [{'label': 'Order', 'sortable': True, 'sort_key': 'order'}, {'label': 'Status', 'sortable': False}], 'rows': [{'id': '1024', 'cells': ['#1024', 'Open'], 'selected': False}, {'id': '1025', 'cells': ['#1025', 'Paid'], 'selected': True}], 'selectable': True, 'responsive': 'stack'}

Guide

Add it to your project

  1. Shape `columns` and row dictionaries in the view.
  2. Include the partial with an id-safe `table_id`.
  3. For bulk selection, wrap it with the bulk-actions bar in one POST form.
  4. Use the documented htmx target and swap contract if progressively enhancing sorting or filtering.

View this component's source for the installed release

Options

Public API

Option Type Default Required Description
table_id str Not set Yes Stable table container id. Constraint: No additional package-level validation is documented beyond this option's stated type and its role in the component.
rows list[dict] Not set Yes Rows, shaped by variant. Constraint: For `records`, each row is `{id, cells, url?, selected?, data?}` and cells align to columns; for `definition`, each row is `{label, value}` and columns are ignored.
columns list[dict] Not set Yes Required for records; ignored for definition. Constraint: Required for `records`: each column record is `{label, sortable(bool), sort_key(str), align?}`. `sort_key` is required when `sortable=True`; `align` is omitted, `start`, `center`, or `end`. Columns are ignored for the `definition` variant.
variant str records No Record list or single-entity facts. Allowed: records, definition. Constraint: Use one of the documented variants; use `danger`, not an invented `error` severity.
loading bool False No Shows skeleton rows. Constraint: Use only for a temporary loading state and replace it with real or error content.
current_sort str Not set No Current server-side sort key. Constraint: No additional package-level validation is documented beyond this option's stated type and its role in the component.
empty_heading str Not set No Empty-state heading. Constraint: No additional package-level validation is documented beyond this option's stated type and its role in the component.
empty_body str Not set No Empty-state body. Constraint: No additional package-level validation is documented beyond this option's stated type and its role in the component.
empty_action_href str Not set No Empty-state action URL. Constraint: No additional package-level validation is documented beyond this option's stated type and its role in the component.
empty_action_label str Not set No Empty-state action label. Constraint: No additional package-level validation is documented beyond this option's stated type and its role in the component.
querystring str Not set No Request-free query string fallback for sort links. Constraint: Preserve non-page filters and remove or replace the page parameter before generating navigation links.
selectable bool False No Record-row selection controls. Constraint: No additional package-level validation is documented beyond this option's stated type and its role in the component.
sticky_header bool False No Pins the header while its wrapper scrolls. Constraint: No additional package-level validation is documented beyond this option's stated type and its role in the component.
responsive str scroll No Narrow-table behaviour. Allowed: scroll, stack. Constraint: Allowed values: `scroll`, `stack`.
table_rows template partial Not set No Rows-only public partial reached as `_data_table.html#table_rows`. Constraint: No additional package-level validation is documented beyond this option's stated type and its role in the component.

Behaviour

Accessibility and responsive behaviour

Accessibility
Semantic headers, row identity, selection labels, and responsive row labels are generated from structured data.
Responsive
Use `scroll` for dense matrix data or `stack` to label cells on small screens.
Without JavaScript
Rows and selection controls are ordinary HTML inputs and links.

Need help?

Troubleshooting

Contract check: pairing rule),

Provide `empty_action_href` and `empty_action_label` together. If either is absent, the documented empty-state branch deliberately renders no action.

Explore next

Used in examples

Documentation