{% extends "vantage/base.html" %} {% load static vantage vantage_inline vantage_slots i18n %} {% block content %} {% comment %} Resolved once, then handed to every tag that needs it. The allowlist behind it walks the model's fields and re-derives what the change form would allow, so recomputing it per cell on a fifty-row page would be a quiet cost on exactly the models that are already the widest. Inside the block on purpose: a tag outside one never runs in a template that extends another. {% endcomment %} {% inline_config panel as inline %}

{{ panel.get_label_plural }}

{% if panel.description %}

{{ panel.description }}

{% endif %}
{% if list_prefs %}{% include "vantage/partials/list_prefs.html" %}{% endif %} {% comment %} Every link carries the active search, filters and sort, so you export what you are looking at rather than the whole table. {% endcomment %}
{% translate "Export" %}
{% if "add" in allowed %} {% icon "upload" %} {% translate "Import CSV" %} {% icon "plus" %} {% blocktranslate with label=panel.get_label|lower %}Add {{ label }}{% endblocktranslate %} {% endif %}
{% slot "before_list" %} {% comment %} One element wraps the whole list: the control band, the filter rail and the table. It carries data-grid, and that is why it is out here rather than around the table alone. vantage-grid.js resolves every control it owns with root.querySelector, so the density and column buttons have to be inside this element — widening it is what allowed four stacked control strips to collapse into one band *above* the two columns. That is also what puts the rail and the data on one baseline: neither column opens with a line of chrome that the other one lacks. The page links are handed over as data rather than scraped from the pagination block below, so `[` and `]` carry the active search, filters and sort — the server already built those URLs correctly once. {% endcomment %}
{% if searchable or rows or saved_view_write_url or timeline_field or patterns.soft_delete or has_active_filters %} {% include "vantage/partials/list_toolbar.html" %} {% endif %} {% comment %} The rail sits beside the table on a wide screen and stacks above it on a narrow one, which is the grid doing it — no second markup path and no breakpoint-specific DOM. {% endcomment %}
{% if filters %} {% endif %}
{% if analytics.stats or analytics.charts or analytics.deferred %} {% include "vantage/partials/analytics.html" %} {% endif %} {% if confirming %}

{{ confirming.label }}?

{{ confirm_text }}

{% blocktranslate %}This will apply to {{ confirm_scope }}.{% endblocktranslate %}

{% csrf_token %} {% for pk in confirm_selection.ids %} {% endfor %} {% for pk in confirm_selection.excluded %} {% endfor %} {% translate "Cancel" %}
{% endif %} {% comment %} The keyboard handlers bind to the table inside the grid element, never to the document, which is how the single-character shortcuts stay compliant with WCAG 2.1.4 (exemption 3, "active only when the component has focus"). {% endcomment %}
{% csrf_token %} {% comment %} Selection exists when there is something to do with it, and bulk edit is something to do with it on every model — so the checkboxes are no longer gated on the panel having declared actions. "change" is the permission the bulk edit page itself requires, so offering the selection to somebody who lacks it would be offering a control that cannot lead anywhere. {% endcomment %} {% if actions or "change" in allowed %} {% endif %}
{% if rows %} {% comment %} role="grid" turns the td elements into gridcells per HTML-AAM, which is what licenses arrow-key navigation inside a thing a screen reader still calls a table. aria-rowcount is the size of the whole result set and aria-rowindex is the absolute position of each row in it, not its position on this page. Without that, page 7 of a 350-record list announces "row 3 of 50" seven times over. Exactly one element in the table carries tabindex="0" (the first row); every focusable thing inside a cell is taken out of the tab order and reached with the arrow keys instead. That is the APG grid contract: Tab enters, Tab leaves. {% endcomment %} {% comment %} Resolved once rather than per row: the column only exists when something fills it, and an empty trailing cell on every row is worse than none. {% endcomment %} {% slot_filled "row_actions" as has_row_actions %} {% if actions or "change" in allowed %} {% endif %} {% comment %} Width and pin are read straight off list_prefs.spec rather than off `column`: `columns` is built in views.py from panel.get_list_display, which already carries a resized/pinned override through the same cascade, but not the width or the pin state themselves — those are presentation only, so they are looked up here instead of threading a new field through a view this feature does not own. data-col-pinned and the width style are only written on the {% endwith %} {% endfor %} {% if has_row_actions %} {% endif %} {% for row in rows %} {% if groups %}{% for g in groups %}{% if g.at == forloop.parentloop.counter0 %} {% endif %}{% endfor %}{% endif %} {% if actions or "change" in allowed %} {% endif %} {% for cell in row.cells %} {% comment %} An editable column renders a control instead of a value. The tag returns nothing for every other column, so the ordinary rendering below is reached by falling through rather than by the template deciding anything about editability — that decision belongs to vantage.inline and is made again, server-side, on every write. {% endcomment %} {% inline_cell inline row.obj columns forloop.counter0 as editor %} {% endfor %} {% if has_row_actions %} {% endif %} {% endfor %} {% if summary %} {% comment %} A single 32px strip carrying one aggregate per numeric column. It is the highest value-per-pixel element on the page, and unlike the stat cards above it needs no configuration to be useful. {% endcomment %} {% if actions or "change" in allowed %}{% endif %} {% for column in columns %} {% endfor %} {% if has_row_actions %}{% endif %} {% endif %}
: a has no column *name* in this loop to look either up against (only the row's own cell values), and table-layout: fixed already applies a 's width to the whole column without any help from its s. vantage-grid.js copies data-col-pinned onto the body and footer cells itself, by data-col-index, the same way it already does for hide/show. {% endcomment %} {% for column in columns %} {% with col_width=list_prefs.spec.column_widths|dict_get:column.name %}
{% if column.sortable %} {{ column.label }} {% if column.active %}{% endif %} {% else %}{{ column.label }}{% endif %} {% comment %} tabindex="-1" like every other in-row control: reached by the grid's own ArrowRight/ArrowLeft cycling, never by Tab — the roving-tabindex contract in vantage-grid.js does not gain a second tab stop just because a column grew a second control. {% endcomment %}
{% comment %} The drag/keyboard resize grip. It sits in its own corner of the cell rather than inside .v-sort, so a pointerdown that starts a drag never lands on the sort link underneath it — see the stopPropagation in vantage-grid.js's own comment on this handle. {% endcomment %}
{% translate "Actions" %}
{{ g.label }}
{% if editor %}{{ editor }} {% elif cell.is_link and row.url %} {% include "vantage/partials/cell_link.html" with url=row.url record=row.obj tabindex="-1" %} {% else %} {{ cell.value }} {% endif %} {% slot "row_actions" object=row.obj %}
{% with cell=summary|dict_get:column.name %} {% if cell %} {{ cell.function }} {{ cell.display }} {% endif %} {% endwith %}
{% elif query or has_active_filters %} {% comment %} State 2: the predicate returned nothing. Echo it back and offer to undo it — never a create CTA, which would imply the list is empty when it is not. The chips above are the "remove the one responsible" half of the same state. {% endcomment %}
{% icon "search" class="v-empty-icon" %} {% if query %}

{% blocktranslate with label=panel.get_label_plural|lower %}No {{ label }} match “{{ query }}”.{% endblocktranslate %}

{% translate "Try a different search, or clear it to see everything." %}

{% translate "Clear search" %} {% else %}

{% blocktranslate with label=panel.get_label_plural|lower %}No {{ label }} match these filters.{% endblocktranslate %}

{% translate "Remove the one responsible above, or clear them all." %}

{% translate "Clear all filters" %} {% endif %}
{% else %} {# State 1: nothing exists yet. #}
{% icon "box" class="v-empty-icon" %}

{% blocktranslate with label=panel.get_label_plural|lower %}No {{ label }} yet.{% endblocktranslate %}

{% translate "They will appear here once the first one is created." %}

{% endif %}
{% if inline.enabled and rows %} {% comment %} Loaded here rather than from base.html, the same arrangement the grid controls and the command palette use: a page with nothing editable on it pays for neither file. {% endcomment %} {% comment %} An empty form, and it is load-bearing. Every inline control carries form="v-inline-form", which re-parents it away from the bulk-action form it is physically nested inside. Without that, pressing Enter in a cell implicitly submits a bulk action, and every cell on the page rides along in the POST body. It must sit outside #v-action-form: HTML forbids nesting forms, and the parser silently drops the inner one, which would put every control back in the action form without anything looking wrong. {% endcomment %} {% comment %} The changeset bar. Persistent rather than a toast: it is the review step, and a review step that fades after four seconds is not one. Hidden until something is staged, and the count goes in the button's own label so nobody commits twelve edits believing they are committing one. {% endcomment %} {% comment %} Announcements for cell mode and for the outcome of a save. A region of its own rather than the grid's, so an edit never overwrites "row 4 of 55" mid-sentence. {% endcomment %}
{% include "vantage/partials/conflict.html" %} {% comment %} After vantage-grid.js in document order, which is what deferred scripts guarantee. That ordering is not cosmetic: the grid's table-scoped keydown listener is registered first, so a cell control that stops an arrow key from propagating is stopping something that already exists rather than racing it. {% endcomment %} {% endif %} {% if rows %} {% comment %} The quick-peek drawer, non-modal on purpose: the list behind it stays live and focus is never trapped, so Tab walks out of it the way it walks out of any other region. A modal peek would be a worse detail page, not a faster one. It is empty until something is peeked; the content is fetched from the record's own detail URL, which means there is exactly one server-side renderer for a record and the drawer can never drift from the page it previews. {% endcomment %} {% endif %} {% if page.paginator.num_pages > 1 %}
{% blocktranslate with start=page.start_index end=page.end_index total=paginator.count %}{{ start }}–{{ end }} of {{ total }}{% endblocktranslate %}
{% if page.has_previous %} {% translate "Previous" %} {% else %} {% endif %} {% if page.has_next %} {% translate "Next" %} {% else %} {% endif %}
{% endif %}
{# .v-list-main #}
{# .v-list-layout #}
{# .v-grid #} {% slot "after_list" %} {% endblock %}