{# filterable_table.html — data table component Contract: ~/.claude/skills/ux-architect/components/data-table.md v0.62 CSS refactor: Tailwind utility soup → semantic .dz-table-* classes from components/table.css. Sticky header + sortable cols + column resize + visibility menu + bulk actions + loading overlay + empty state — all chrome lives in CSS now. Alpine state grammar (loading, colMenuOpen, isColumnVisible, bulkCount, columnWidths) unchanged; semantic classes layer over the same :class / x-show bindings. #} {% if table %} {%- set config_json = { "sortField": table.default_sort_field, "sortDir": table.default_sort_dir, "inlineEditable": table.inline_editable, "bulkActions": table.bulk_actions, "entityName": table.entity_name } | tojson -%}
`, `&`, `'` but NOT `"` since they are the JSON string delimiter. With a double-quoted attribute the first `"` inside the JSON prematurely terminates the attribute, leaving Alpine to parse the malformed `dzTable(..., {` expression — which cascades into every dzTable state name (loading, colMenuOpen, isColumnVisible, selected, bulkCount, columnWidths) reading as "undefined". That was the actual root cause of issue #804. Keeping this comment as a load-bearing reminder for anyone tempted to "tidy up" the quoting here. #} x-data='dzTable("{{ table.table_id }}", "{{ table.api_endpoint }}", {{ config_json }})' :aria-busy="loading" data-dz-bulk-count="0" class="dz-table"> {# ── Table header: title + actions ───────────────────────────────────────── #} {# #983 — visually-hidden h1 landmark so list pages have a proper "page title" in the accessibility tree. Pre-#983 the table emitted only an h2; screenreaders had nothing at h1 to anchor on. The table-title h2 stays as the visible heading — promoting it to h1 would conflict with the workspace shell h1 on dashboard pages. #} {% if page_title %}

{{ page_title }}

{% endif %} {% block table_header %}

{{ table.title }}

{# Column visibility menu #} {% if table.columns | length > 3 %}
{# Column visibility dropdown #}
{% endif %} {# Create button #} {% if table.create_url %} New {{ table.entity_name | replace("_", " ") }} {% endif %}
{% endblock table_header %} {# ── Toolbar: search · filters · bulk actions ─────────────────────────────── #}
{# Search — left #} {% if table.search_enabled %} {% with endpoint=table.api_endpoint, target='#' + table.table_id + '-body' %} {% include 'fragments/search_input.html' with context %} {% endwith %} {% endif %} {# Filter chips — centre, flex-grow #} {%- set has_filters = table.columns | selectattr('filterable') | list | length > 0 -%} {% if has_filters %}
{% include 'fragments/filter_bar.html' with context %}
{% endif %} {# Bulk actions — right, visible when rows selected #} {% if table.bulk_actions %} {% with entity_name=table.entity_name, actions_endpoint=table.api_endpoint %} {% include 'fragments/bulk_actions.html' with context %} {% endwith %} {% endif %}
{# ── Table scroll + loading overlay container ─────────────────────────────── #} {# --dz-list-rows feeds the min-height reservation in table.css that fixes #965's list-surface CLS. The CSS clamps to 10 internally so very large page_sizes don't reserve absurd vertical space when actual results are small. #}
{# Loading overlay — sits above the table, dimmed backdrop. #972: pre-fix this used `x-show="loading"` against the ancestor dzTable's `x-data` scope. Idiomorph re-evaluated the binding on morph before Alpine re-established the parent scope, throwing "loading is not defined" — the same failure mode as #970. Fixed per ADR-0022's preferred pattern: pure CSS keyed off htmx's `.htmx-request` class, applied to the request-initiating element (the outer dzTable wrapper). No Alpine binding, no morph race. #} {# Horizontal scroll region #}
{# colgroup — one per visible column. Drives column-resize via pointer events. #} {% if table.bulk_actions %} {% endif %} {% for col in table.columns %} {% if not col.hidden %} {% endif %} {% endfor %} {# Actions column #} {# ── Sticky header ─────────────────────────────────────────────────── #} {# Select-all checkbox #} {% if table.bulk_actions %} {% endif %} {# Column headers #} {% for col in table.columns %} {% if not col.hidden %} {% endif %} {% endfor %} {# Actions column header #} {# ── Table body — loaded via HTMX ──────────────────────────────────── #} {% include 'fragments/table_rows.html' %}
{{ table.title }}
{% if col.sortable %} {# Sortable header button #} {% else %} {{ col.label }} {% endif %} {# Resize handle — 4px wide, right edge of th #} Actions
{# Empty state — visible (via :has CSS selector) when tbody has no data rows #}
{# Empty icon #}

No {{ table.entity_name | replace("_", " ") | lower }}s found

Try adjusting your search or filter criteria.

{% if table.create_url %} New {{ table.entity_name | replace("_", " ") }} {% endif %}
{# /dz-table-scroll-x #}
{# /dz-table-scroll #} {# Screen-reader-only HTMX loading indicator (real indicator managed by Alpine loading flag above) #}
Loading…
{# ── Footer: pagination ───────────────────────────────────────────────────── #} {% if table.pagination_mode != "infinite" %} {% endif %} {# Slide-over panel for detail views #} {% if table.slide_over %} {% include 'components/alpine/slide_over.html' %} {% endif %} {# Screen reader live region for Alpine announcements #}
{% endif %}