Metadata-Version: 2.4
Name: django-cards
Version: 1.6.0
Summary: Django app that allows you make cards
Author: Thomas Turner
License: MIT
Project-URL: Homepage, https://github.com/django-advance-utils/django-cards
Classifier: Programming Language :: Python :: 3
Classifier: License :: OSI Approved :: MIT License
Classifier: Operating System :: OS Independent
Requires-Python: >=3.7
Description-Content-Type: text/markdown
License-File: LICENSE
Dynamic: license-file

[![PyPI version](https://badge.fury.io/py/django-cards.svg)](https://badge.fury.io/py/django-cards)
[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT)

# django-cards

A Django library for building rich, interactive detail cards in your views — with 30+ display options, AJAX reload, search, export, datatables, and more.

---

## Overview

django-cards gives you a Python API to build Bootstrap-styled information cards directly from your Django views. Instead of writing repetitive template HTML, you declare cards and entries in Python:

- **12 card types** — standard detail, table, HTML, datatable, ordered datatable, list selection, layout/group, message, linked datatables, accordion, panel layout, and iframe
- **30+ entry display options** — badges, icons, sparklines, ratings, progress bars, status dots, popovers, copy-to-clipboard, and more
- **Interactive features** — AJAX reload, client-side search, CSV/JSON export, collapsible cards
- **Layout system** — card groups, layout cards, and child card groups for complex page layouts
- **List & tree views** — built-in list-detail and tree-detail patterns with `CardList` and `CardTree`
- **Datatable integration** — embed [django-datatables](https://github.com/django-advance-utils/django-datatables) with drag-and-drop ordering

## Installation

```bash
pip install django-cards
```

Add to `INSTALLED_APPS`:

```python
INSTALLED_APPS = [
    ...
    'cards',
]
```

### Dependencies

django-cards requires:
- [ajax-helpers](https://github.com/django-advance-utils/ajax-helpers)
- [django-menus](https://github.com/django-advance-utils/django-menus)
- [django-datatables](https://github.com/django-advance-utils/django-datatables) (for datatable card types)

## Bootstrap 4 and Bootstrap 5

Cards render for both, from a template pack per version:

```
cards/templates/cards/bootstrap4/
cards/templates/cards/bootstrap5/
```

One setting picks between them, and Bootstrap 4 is the default:

```python
DJANGO_CARDS_TEMPLATE_PACK = 'bootstrap5'
```

A pack emits only its own version's names, so the rendered page carries no classes the browser
will ignore, and the two are free to diverge where Bootstrap 5 changed more than a name -- the
lightbox close button is the worked example, since Bootstrap 4's `.close` styles a `&times;`
the markup supplies while Bootstrap 5's `.btn-close` must be empty and draws its own.

The setting may instead be a dotted path to a callable taking the request and returning a pack
name, for a project that has to serve both:

```python
DJANGO_CARDS_TEMPLATE_PACK = 'myapp.bootstrap.pack_for_request'

def pack_for_request(request):
    return 'bootstrap5' if request and request.user.is_staff else 'bootstrap4'
```

A pack name never contains a dot, which is what tells the two forms apart.

**What this means when you pass your own classes.** Anywhere cards takes a CSS class from you
-- `div_css_class`, `css_class`, `table_td_css_class`, `badge`, a column's `css_class`, the
`list_class` / `details_class` on `CardList` -- the string is passed through untouched, so
write it for the version you are on:

```python
# on the bootstrap4 pack                     # on the bootstrap5 pack
div_css_class='col-6 float-left'             div_css_class='col-6 float-start'
css_class='text-right'                       css_class='text-end'
badge='badge-success'                        badge='text-bg-success'
```

The renames worth knowing: `float-left`/`float-right` → `float-start`/`float-end`,
`text-left`/`text-right` → `text-start`/`text-end`, `ml-*`/`mr-*` → `ms-*`/`me-*`,
`pl-*`/`pr-*` → `ps-*`/`pe-*`, `font-weight-bold` → `fw-bold`, `badge-*` → `text-bg-*`,
`badge-pill` → `rounded-pill`, `btn-block` → `w-100`.

**Overriding a template still works by the old name.** `cards/standard/default.html` and the
rest are still the paths cards renders, and are still what a project overrides, includes or
names in a `CardBase.templates` subclass. What sits at each of them now is a one-line
forwarder to the pack, so a file of your own at that path shadows it exactly as before -- and
applies to both packs. To replace one version only, put your file at the pack path instead:

```
myproject/templates/cards/standard/default.html      both packs
myproject/templates/cards/bootstrap5/standard/default.html   Bootstrap 5 only
```

**A few class names are not in a template**, because they are built in Python or written by
the treegrid's JavaScript: the two badges, the column filter selects, the count beside a
filter value and the gap after a context menu icon. They live in `cards.packs.PACK_CLASSES`,
one entry per pack, and are picked with the same setting.

One difference is not a rename and cannot be dualled. Bootstrap 4 gave `col-*` a fixed 15px of
padding; Bootstrap 5 takes it from a `--bs-gutter-x` variable that `.row` sets. Card groups are
emitted at the top level rather than inside a `.row`, so a `col-*` group that had gutters under
4 sits flush under 5. Add your own spacing -- `px-2`, or wrap the groups in a `.row` -- if you
want them back.

**jQuery is required, and under Bootstrap 5 the order matters.** ajax-helpers already loads
it, so this is normally free -- but Bootstrap 5 dropped jQuery as a dependency and only
registers its jQuery plugin interface if jQuery is already on the page when it loads. Several
cards go through that interface: tooltips and popovers, the image gallery's lightbox, and the
accordion's expand/collapse handlers. Load jQuery before `bootstrap.bundle.js`.

**The rest of the stack is not all there yet.** django-menus has the same mechanism, under
its own `DJANGO_MENUS_TEMPLATE_PACK` -- set both to the same version. django-modals,
django-datatables and crispy's `bootstrap4` template pack still emit Bootstrap 4 markup only,
so under Bootstrap 5 your cards will be correct while the modals around them and the
datatables in them are not, until those libraries get the same treatment.

To see the difference, the example app serves either version, and the nav bar carries a toggle
that flips between them on the page you are looking at -- no restart:

```bash
python manage.py runserver          # starts on Bootstrap 4
```

`?bootstrap=5` on any URL does the same thing and sticks for the session.
`CARDS_EXAMPLE_BOOTSTRAP=5` in the environment sets where a fresh session starts.

## Quick Start

### 1. Create a view

```python
from cards.standard import CardMixin
from django.views.generic import DetailView

class CompanyDetailView(CardMixin, DetailView):
    model = Company
    template_name = 'company/detail.html'

    def setup_cards(self):
        card = self.add_card('info', title='Company Info', details_object=self.object)
        card.add_entry(field='name')
        card.add_entry(field='active')
        card.add_entry(field='importance')

        self.add_card_group('info', div_css_class='col-12')
```

### 2. Use in your template

```html
{% load django_cards_tags %}

<div class="row">
    {{ card_groups }}
</div>
```

Or render individual cards:

```html
{% load django_cards_tags %}

{% for card in cards.values %}
    {% show_card card %}
{% endfor %}
```

---

## Card Types

| Constant | Type | Description |
|---|---|---|
| `CARD_TYPE_STANDARD` (1) | Standard | Label/value detail card (default) |
| `CARD_TYPE_DATATABLE` (2) | Datatable | Embedded datatable |
| `CARD_TYPE_ORDERED_DATATABLE` (3) | Ordered Datatable | Datatable with drag-and-drop row ordering |
| `CARD_TYPE_HTML` (4) | HTML | Arbitrary HTML content |
| `CARD_TYPE_LIST_SELECTION` (5) | List Selection | Scrollable selectable list panel |
| `CARD_TYPE_CARD_GROUP` (6) | Card Group | Group of cards with a shared header |
| `CARD_TYPE_CARD_LAYOUT` (7) | Layout | Headerless nested layout container |
| `CARD_TYPE_CARD_MESSAGE` (8) | Message | Alert/warning message card |
| `CARD_TYPE_LINKED_DATATABLES` (9) | Linked Datatables | Side-by-side datatables with drill-down filtering |
| `CARD_TYPE_ACCORDION` (10) | Accordion | Collapsible panels containing any card type |
| `CARD_TYPE_PANEL_LAYOUT` (11) | Panel Layout | CSS Grid resizable/collapsible panel regions |
| `CARD_TYPE_IFRAME` (12) | Iframe | Embedded external URL or inline HTML content |
| `CARD_TYPE_TREEGRID` (13) | Treegrid | Fancytree hierarchical grid with lazy loading, filters, and editing |

Import constants from `cards.base`:

```python
from cards.base import (CARD_TYPE_STANDARD, CARD_TYPE_DATATABLE, CARD_TYPE_HTML,
                        CARD_TYPE_LINKED_DATATABLES, CARD_TYPE_ACCORDION,
                        CARD_TYPE_PANEL_LAYOUT, CARD_TYPE_IFRAME)
```

---

## Entry Parameters Reference

The `add_entry()` method accepts 30+ parameters to control how each row is displayed.

### Core

| Parameter | Type | Default | Description |
|---|---|---|---|
| `value` | any | `None` | Direct value to display |
| `field` | str | `None` | Field name on `details_object` (supports `__` traversal, e.g. `'category__name'`) |
| `label` | str | `None` | Row label (auto-generated from `field` if omitted) |
| `default` | str | `'N/A'` | Fallback when value is None or empty |
| `hidden` | bool | `False` | Skip rendering this entry entirely |
| `hidden_if_blank_or_none` | bool | `None` | Hide row if value is blank or None |
| `hidden_if_zero` | bool | `None` | Hide row if value is 0 |

### Links & Navigation

| Parameter | Type | Default | Description |
|---|---|---|---|
| `link` | str/callable | `None` | URL — makes the entire row a hyperlink |
| `value_link` | str | `None` | URL wrapping only the value (not the label) |
| `auto_link` | bool | `False` | Auto-detect URLs and emails in text and make them clickable |

```python
card.add_entry(value='Visit https://example.com for details', label='Website', auto_link=True)
card.add_entry(value='Contact support@example.com', label='Email', auto_link=True)
```

### Display

| Parameter | Type | Default | Description |
|---|---|---|---|
| `badge` | bool/str | `None` | `True` for default badge (`bg-secondary`), or a CSS class string |
| `icon` | str | `None` | Font Awesome class (e.g. `'fas fa-envelope'`) |
| `prefix` | str | `None` | Text before the value |
| `suffix` | str | `None` | Text after the value |
| `status_dot` | str | `None` | CSS color for a dot indicator (e.g. `'green'`, `'#ff0000'`) |
| `progress_bar` | bool/str | `None` | `True` for default bar, or a CSS class (value is percentage) |
| `image` | bool/str | `None` | `True` for 40px height, or custom height string; value is image URL |
| `rating` | bool/int | `None` | `True` for 5 stars, or int for custom max; value is filled count |
| `sparkline` | bool/str | `False` | `True` for line chart, `'bar'` for bar chart; value is a list of numbers |
| `boolean_icon` | bool | `False` | Show check/cross icon for boolean values |

```python
card.add_entry(value='Active',   label='Status',  badge=True)
card.add_entry(value='Overdue',  label='Payment', badge='bg-danger')
card.add_entry(value='Premium',  label='Plan',    icon='fas fa-crown', badge='bg-warning text-dark')

card.add_entry(value='Active',   label='Server',  status_dot='green')
card.add_entry(value=75,         label='Progress', progress_bar=True)
card.add_entry(value=90,         label='Disk',    progress_bar='bg-danger')

card.add_entry(value=4,          label='Rating',   rating=True)      # 4 out of 5 stars
card.add_entry(value=7,          label='Score',    rating=10)        # 7 out of 10 stars

card.add_entry(value=[10, 25, 15, 30, 20, 35, 28], label='Trend',   sparkline=True)
card.add_entry(value=[5, 10, 3, 8, 12, 6, 9],      label='Volume',  sparkline='bar')

card.add_entry(value=True,  label='Active',   boolean_icon=True)
card.add_entry(value=False, label='Verified', boolean_icon=True)
```

### Formatting

| Parameter | Type | Default | Description |
|---|---|---|---|
| `number_format` | bool/int | `None` | `True` for comma-separated integers, int for decimal places |
| `truncate` | int | `None` | Max characters before truncation with ellipsis (full text in tooltip) |
| `timestamp` | bool | `False` | Display as "X ago" with full datetime in tooltip |
| `placeholder` | str/bool | `None` | Muted/italic placeholder when value is empty |

```python
card.add_entry(value=1234567,     label='Population', number_format=True)    # "1,234,567"
card.add_entry(value=1234567.891, label='Revenue',    number_format=2, prefix='$')  # "$1,234,567.89"
card.add_entry(value='A very long description that should be cut off', label='Desc', truncate=30)
card.add_entry(field='created_date', timestamp=True)  # "2 hours ago"
```

### Interactivity

| Parameter | Type | Default | Description |
|---|---|---|---|
| `tooltip` | str | `None` | Bootstrap tooltip text on hover |
| `popover` | str/dict | `None` | Popover content; string or `{'title': '...', 'content': '...'}` |
| `copy_to_clipboard` | bool | `False` | Adds a copy button next to the value |
| `help_text` | str | `None` | Small muted text displayed below the value |

```python
card.add_entry(value='Hover me', label='Tooltip', tooltip='Extra information here')

card.add_entry(value='Click me', label='Popover',
               popover='Simple popover content')
card.add_entry(value='Click me', label='Rich Popover',
               popover={'title': 'Details', 'content': 'Popover with title and content'})

card.add_entry(value='sk-abc123def456ghi789', label='API Key', copy_to_clipboard=True)
card.add_entry(field='name', help_text='The primary display name')
```

### Conditional Display

| Parameter | Type | Default | Description |
|---|---|---|---|
| `show_if` | callable | `None` | `show_if(details_object) -> bool` — only show if returns True |
| `css_class_method` | callable | `None` | `css_class_method(value) -> str` — dynamic CSS class based on value |
| `default_if` | callable | `None` | Conditionally apply the default value |

```python
card.add_entry(field='name', show_if=lambda obj: obj.active)

card.add_entry(value=150, label='Balance',
               css_class_method=lambda v: 'text-success' if v >= 0 else 'text-danger')

card.add_entry(value='HIGH', label='Priority',
               css_class_method=lambda v: {'HIGH': 'text-danger fw-bold',
                                           'MEDIUM': 'text-warning',
                                           'LOW': 'text-success'}.get(v, ''))
```

### Diff / Change Indicator

| Parameter | Type | Default | Description |
|---|---|---|---|
| `old_value` | any | `None` | Shows old value as strikethrough with arrow to new value |

```python
card.add_entry(value='Active', label='Status', old_value='Pending')
card.add_entry(value=1500, label='Revenue', old_value=1200, number_format=True, prefix='$')
```

### Layout & Styling

| Parameter | Type | Default | Description |
|---|---|---|---|
| `row_style` | str | `None` | Named row style defined via `add_row_style()` |
| `separator` | bool | `False` | Render an `<hr>` separator before this entry |
| `entry_css_class` | str | `None` | CSS class for the value element |
| `css_class` | str | `None` | CSS class for the row container |
| `html_override` | str | `None` | Custom HTML — use `%1%` as value placeholder |
| `value_method` | callable | `None` | Transform the value before rendering |
| `value_type` | str | `None` | Rendering hint (`'currency'`, `'boolean'`, `'m2m'`, etc.) |

### Additional kwargs

These can be passed via `**kwargs`:

| Parameter | Type | Default | Description |
|---|---|---|---|
| `merge` | bool | — | Join list values into a single string |
| `merge_string` | str | `' '` | Separator when merging list values |
| `m2m_field` | str | — | Attribute name on M2M related objects to display |
| `query_filter` | dict | — | Filter for M2M querysets |

---

## Card-Level Options

These are passed to `add_card()` or `CardBase.__init__()`:

| Parameter | Type | Default | Description |
|---|---|---|---|
| `title` | str | `None` | Card heading text |
| `show_header` | bool | `True` | Whether to show the card header |
| `header_icon` | str | `None` | CSS icon class for header (e.g. `'fas fa-user'`) |
| `header_css_class` | str | `''` | CSS class for the header div |
| `footer` | str | `None` | Footer HTML content |
| `menu` | list/HtmlMenu | `None` | Action menu items in the header |
| `tab_menu` | list/HtmlMenu | `None` | Tab menu items in the header |
| `collapsed` | bool | `None` | `None`=no collapse; `False`=collapsible, open; `True`=collapsible, closed |
| `template_name` | str | `None` | Template key (`'default'`, `'table'`, `'blank'`) or custom path |
| `ajax_reload` | bool | `False` | Enable AJAX reload button |
| `reload_interval` | int | `None` | Auto-reload interval in seconds (requires `ajax_reload=True`) |
| `searchable` | bool | `False` | Adds a search input that filters card rows client-side |
| `exportable` | bool | `False` | Adds CSV/JSON export dropdown button |
| `show_created_modified_dates` | bool | `False` | Show created/modified timestamps from the details object |
| `column_search` | bool | `False` | Adds per-column search inputs to the header row (treegrid cards) |
| `border` | str/bool | `None` | Card chrome: `None` (or `True`) keeps the default Bootstrap card border, `'thin'` draws a 1px hairline, `'none'` (or `False`) removes the border |
| `details_object` | object | `None` | The data object for field-based entries |
| `is_empty` | bool | `False` | Render as empty state |
| `empty_message` | str | `'N/A'` | Message shown when card is empty |
| `hidden_if_blank_or_none` | list | `None` | Card-wide list of fields to hide when blank/None |
| `hidden_if_zero` | list | `None` | Card-wide list of fields to hide when zero |
| `extra_card_context` | dict | `None` | Extra context passed to the card template |

```python
card = self.add_card('profile',
                     title='User Profile',
                     details_object=user,
                     header_icon='fas fa-user',
                     header_css_class='bg-primary text-white',
                     footer='Last updated: today',
                     collapsed=False,
                     ajax_reload=True,
                     reload_interval=30,
                     searchable=True,
                     exportable=True)
```

### Thin and borderless cards

Pass `border='thin'` for a 1px hairline around the card — the same look as a compact
detail box on a purchase-order page. Pass `border='none'` (or `False`) to drop the box
entirely, which is how you get a card that is just icons sitting on the page:

```python
# 1px hairline, like Purchase Order Details / Delivery Address
details = self.add_card('po_details', title='Purchase Order Details',
                        template_name='table', border='thin',
                        extra_card_context={'table_css_class': 'table table-sm mb-0'})
details.add_entry(value='25/03/2026', label='Order Date')
details.add_entry(value='GBP', label='Currency')

# No chrome at all — just the icons
self.add_html_data_card(
    '<div class="d-flex align-items-center" style="gap:0.75rem">'
    '<i class="fas fa-print fa-lg text-secondary"></i>'
    '<i class="fas fa-cog fa-lg text-secondary"></i>'
    '<i class="fas fa-file-alt fa-lg text-secondary"></i>'
    '</div>',
    card_name='po_actions',
    show_header=False,
    border='none',
)
```

The stylesheet that draws these looks after itself: `cards.css` is emitted once per
request, immediately before the first card that asks for a non-default border, so there
is no `lib_include` to add and nothing to change in your base template. Cards left on
the default chrome never pull it in.

#### Icons in a compact header

django-menus gives every menu item the `.btn` class, and a button is taller than the
title beside it. On the Card Borders example a thin header with two icon buttons measured
44.8px, against 32px for the same header with no menu — so the compact chrome bought
nothing. `django-card__header-icon` ships in the same stylesheet and brings it back to
32px, identical to having no menu at all:

```python
menu = [MenuItem('app:edit_modal', menu_display='', font_awesome='fas fa-edit',
                 css_classes='django-card__header-icon',
                 attributes={'title': 'Edit', 'aria-label': 'Edit'})]
card = self.add_card('po_details', title='Purchase Order Details',
                     border='thin', menu=menu)
```

An icon-only item has no text for a screen reader to read, so give it a `title` and an
`aria-label`. The class itself lives in `cards.css`, which is only emitted for cards with
a non-default `border=` — on a page with no bordered card the class styles nothing.

It is meant for icon-only items (`menu_display=''`); an item with a text label wants the
button. Buttons in the card *body* are unaffected — this is only about what sits on the
header row next to the title.

`border` styles the card itself and is separate from `treegrid_borderless`, which
controls the grid lines *inside* a treegrid card. They compose: a treegrid card can have
`border='thin'` around the card and `treegrid_borderless=True` within it.

#### Compact card layouts

The standard and table templates take the same inline style hooks the html template has,
passed through `extra_card_context`, which covers the rest of a compact detail-box page:

| Key | Effect |
| --- | --- |
| `card_css_style` | Inline style on the card itself, e.g. `width:fit-content` |
| `card_body_css_style` | Inline style on the body, e.g. `max-height:600px;overflow:auto` to scroll a long card in place |
| `table_css_class` | The table's classes, e.g. `table table-sm mb-1` |
| `table_td_css_class` | The value cell's classes, e.g. `text-right` |

`add_entry(row_css_class=...)` puts a class on the row itself — in the table template
that is the `<tr>`, so a line can be highlighted with `row_css_class='table-warning'`.
It is separate from `css_class`, which styles the value heading and is left alone. Pass
`hidden_if_blank_or_none=True` on the card and entries with no value drop out entirely
rather than rendering a blank row.

```python
card = self.add_card('po_details', title='Purchase Order Details',
                     template_name='table', border='thin',
                     hidden_if_blank_or_none=True,
                     extra_card_context={'table_css_class': 'table table-sm mb-1',
                                         'table_td_css_class': 'text-right',
                                         'card_body_css_style': 'max-height:600px;overflow:auto'})
card.add_entry(value='GBP', label='Currency')
card.add_entry(value='3 lines overdue', label='Status', row_css_class='table-warning')
card.add_entry(value='', label='Free Issue')   # no value, so no row
```

See the Purchase Order Layout example page for the whole thing.

### Table Template

Use `template_name='table'` for a table-style layout:

```python
card = self.add_card('details', title='Details', template_name='table',
                     extra_card_context={'table_css_class': 'table table-bordered'})
card.add_entry(value='Hello', label='Greeting')
```

---

## Card Groups & Layouts

### Card Groups

Arrange cards into Bootstrap grid columns:

```python
def setup_cards(self):
    self.add_card('profile', title='Profile', details_object=self.object)
    self.add_card('stats',   title='Statistics', details_object=self.object)
    self.add_card('notes',   title='Notes', details_object=self.object)

    # Two-column layout
    self.add_card_group('profile', 'stats', div_css_class='col-6 float-left')
    self.add_card_group('notes', div_css_class='col-6 float-right')
```

`add_card_group()` parameters:

| Parameter | Type | Default | Description |
|---|---|---|---|
| `*args` | str/CardBase | — | Card names or card objects to include |
| `div_css_class` | str | `''` | CSS class for the container div |
| `div_css` | str | `''` | Inline CSS styles |
| `div_id` | str | `''` | HTML id for the container |
| `script` | str | `''` | JavaScript to include in a `<script>` tag after the group |
| `group_title` | str | `''` | Heading displayed above the group |
| `group_code` | str | `'main'` | Group identifier |

### Layout Cards

For nesting cards inside a card (with or without a header):

```python
def setup_cards(self):
    child1 = self.add_card(title='Left Panel')
    child1.add_entry(value='Hello', label='Greeting')

    child2 = self.add_card(title='Right Panel', template_name='table')
    child2.add_entry(value='World', label='Target')

    layout = self.add_layout_card()
    layout.add_child_card_group(child1, div_css_class='col-6 float-left')
    layout.add_child_card_group(child2, div_css_class='col-6 float-left')

    self.add_card_group(layout, div_css_class='col-12')
```

Use `CARD_TYPE_CARD_GROUP` instead for a layout card **with** a header and menu:

```python
from cards.base import CARD_TYPE_CARD_GROUP

card = self.add_card('overview', title='Overview', group_type=CARD_TYPE_CARD_GROUP, menu=my_menu)
card.add_child_card_group(child1, div_css_class='col-6 float-left')
card.add_child_card_group(child2, div_css_class='col-6 float-left')
```

---

## Multi-Entry Rows

### add_row()

Place multiple entries side by side in a single row:

```python
card.add_row('first_name', 'last_name')                # Two columns
card.add_row('city', 'state', 'zip_code')               # Three columns
card.add_row('field1', 'field2', 'field3', 'field4')    # Four columns
```

Columns are automatically sized using Bootstrap grid classes (`col-sm-6` for 2, `col-sm-4` for 3, `col-sm-3` for 4).

You can also pass dicts for full control:

```python
card.add_row({'field': 'email', 'icon': 'fas fa-envelope'},
             {'field': 'phone', 'icon': 'fas fa-phone'})
```

### add_rows()

Bulk-add entries — each argument can be a string (field name), dict (entry kwargs), or list/tuple (passed to `add_row()`):

```python
card.add_rows(
    'name',                                            # Single entry
    {'field': 'email', 'icon': 'fas fa-envelope'},     # Dict entry
    ['first_name', 'last_name'],                       # Multi-column row
    [{'field': 'city', 'label': 'City'}, 'state'],    # Mixed row
)
```

---

## Custom Row Styles

Define custom HTML layouts for entries using `add_row_style()`:

```python
from ajax_helpers.html_include import HtmlDiv, HtmlElement

card.add_row_style('header_style', html=HtmlDiv([
    HtmlElement(element='span', contents=[
        HtmlElement(element='h4', contents='{label}')
    ]),
    HtmlElement(element='span', contents='{value}')
]))

card.add_entry(value='Custom layout', label='Title', row_style='header_style')
```

Set a default style for all subsequent entries:

```python
card.add_row_style('compact', html='<div class="compact">{label}: {value}</div>')
card.set_default_style('compact')

card.add_entry(value='Uses compact style', label='A')
card.add_entry(value='Also compact', label='B')
```

### HTML Entries

Insert raw HTML or rendered templates as card rows:

```python
card.add_html_entry(template_name='myapp/custom_entry.html', context={'key': 'value'}, colspan=2)
card.add_html_string_entry('<div class="custom">Raw HTML content</div>')
```

---

## CardList & CardTree

### CardList — List-Detail Pattern

A two-panel layout with a selectable list on the left and detail cards on the right:

```python
from cards.card_list import CardList
from django.views.generic import TemplateView

class CompanyListView(CardList, TemplateView):
    template_name = 'myapp/cards.html'
    list_title = 'Companies'
    model = Company

    def get_details_title(self, details_object):
        return details_object.name

    def get_details_menu(self, details_object):
        return [MenuItem('myapp:edit', menu_display='Edit', url_args=[details_object.pk])]

    def get_details_data(self, card, details_object):
        card.add_rows('name', 'active', 'importance')
        card.add_entry(field='company_category__name', label='Category')
```

Key class attributes:

| Attribute | Default | Description |
|---|---|---|
| `model` | `None` | Django model for list entries |
| `list_title` | `''` | Heading for the list panel |
| `list_class` | `'col-sm-5 col-md-4 col-lg-3 float-left'` | CSS class for list panel. The float follows the template pack (`float-start` on Bootstrap 5); setting your own string replaces the lot |
| `details_class` | `'col-sm-7 col-md-8 col-lg-9 float-left'` | CSS class for details panel, as above |

Key methods to override:

| Method | Purpose |
|---|---|
| `get_details_data(card, details_object)` | Populate the detail card entries |
| `get_details_title(details_object)` | Return the detail card title |
| `get_details_menu(details_object)` | Return menu items for the detail card |
| `get_list_entries()` | Return the queryset for list items |
| `get_list_entry_name(entry_object)` | Return display name for a list item |
| `get_list_colour(entry_object)` | Return optional colour for a list item |

### CardTree — Tree-Detail Pattern

A two-panel layout with a jsTree navigation on the left:

```python
from cards.card_list import CardTree
from django.views.generic import TemplateView

class CategoryTreeView(CardTree, TemplateView):
    template_name = 'myapp/cards.html'
    list_title = 'Categories'

    def get_tree_data(self, selected_id):
        return [
            {'id': '1', 'parent': '#',  'text': 'Root Node'},
            {'id': '2', 'parent': '#',  'text': 'Another Root'},
            {'id': '3', 'parent': '2',  'text': 'Child Node', 'icon': 'fas fa-folder'},
            {'id': '4', 'parent': '2',  'text': 'Another Child'},
        ]

    def get_details_data(self, card, details_object):
        card.add_entry(value=details_object, label='Selected ID')
```

Override `get_tree_data(selected_id)` to return a list of node dicts with `id`, `parent` (`'#'` for root), `text`, and optionally `icon` and `state`.

---

## Datatables

### Datatable Card

Embed a django-datatables table inside a card:

```python
from cards.base import CARD_TYPE_DATATABLE

class MyView(CardMixin, TemplateView):
    ajax_commands = ['datatable', 'row', 'column']

    def setup_datatable_cards(self):
        self.add_card('companies',
                      title='Companies',
                      group_type=CARD_TYPE_DATATABLE,
                      datatable_model=Company,
                      collapsed=False)

    def setup_cards(self):
        self.add_card_group('companies', div_css_class='col-12')

    def setup_table_companies(self, table, details_object):
        table.ajax_data = True
        table.add_columns('id', 'name', 'importance')
```

The `setup_table_<card_name>()` method is called automatically to configure the table.

### Ordered Datatable

Adds drag-and-drop row reordering:

```python
from cards.base import CARD_TYPE_ORDERED_DATATABLE

def setup_datatable_cards(self):
    self.add_card('statuses',
                  title='Statuses',
                  group_type=CARD_TYPE_ORDERED_DATATABLE,
                  datatable_model=Status)
```

---

## AJAX Reload

### Button Reload

Enable the reload button on a card:

```python
card = self.add_card('live_data', title='Live Data', ajax_reload=True)
```

### Auto-Reload Interval

Automatically refresh a card every N seconds:

```python
card = self.add_card('dashboard', title='Dashboard', ajax_reload=True, reload_interval=30)
```

### Programmatic Reload

Trigger a card reload from a button handler:

```python
def button_update(self, **kwargs):
    # ... perform update ...
    self.reload_card('live_data')
```

### WebSocket Push Reload

Use `CardReloadConsumer` with Django Channels for server-pushed card reloads:

```python
# routing.py
from cards.channels import CardReloadConsumer

websocket_urlpatterns = [
    path('ws/cards/', CardReloadConsumer.as_asgi()),
]
```

---

## HTML & Message Cards

### HTML Card (from template)

```python
card = self.add_html_card('myapp/chart.html', context={'data': chart_data}, title='Chart')
```

### HTML Card (from string)

```python
card = self.add_html_data_card('<div class="alert alert-info">Custom HTML</div>', title='Info')
```

### Message Card

```python
card = self.add_message_card(title='Warning', message='No data available for this period.')
```

### Link Gallery Card

Display a visual gallery of links as uniform 120px-height tiles. Supports multiple link types: images (thumbnail + lightbox), data sheets (PDF icon + new tab), product pages (web icon + new tab), and other links (link icon + new tab).

```python
links = [
    {'url': 'https://example.com/front.jpg', 'name': 'Front View', 'type': 'image'},
    {'url': 'https://example.com/side.jpg', 'name': 'Side View', 'type': 'image'},
    {'url': 'https://example.com/datasheet.pdf', 'name': 'Data Sheet', 'type': 'data_sheet'},
    {'url': 'https://example.com/product', 'name': 'Product Page', 'type': 'product_page'},
    {'url': 'https://example.com/other', 'name': 'Other Link', 'type': 'other'},
]
card = self.add_link_gallery_card(links, card_name='links', title='Links')

# Optionally show names below image thumbnails
card = self.add_link_gallery_card(links, card_name='links', title='Links', show_image_names=True)

# Optionally add edit buttons to individual tiles by supplying 'edit_url' on any item
links = [
    {'url': 'https://example.com/front.jpg', 'name': 'Front View', 'type': 'image', 'edit_url': '/images/1/edit/'},
    {'url': 'https://example.com/datasheet.pdf', 'name': 'Data Sheet', 'type': 'data_sheet'},
]
card = self.add_link_gallery_card(links, card_name='links', title='Links')
```

`add_link_gallery_card()` parameters:

| Parameter | Type | Default | Description |
|---|---|---|---|
| `links` | list[dict] | — | List of dicts with `'url'`, `'type'` (required), `'name'` and `'edit_url'` (optional) keys |
| `card_name` | str | `None` | Unique card identifier |
| `title` | str | `'Links'` | Card header title |
| `show_image_names` | bool | `False` | Show name labels below image thumbnails |
| `**kwargs` | | | Additional keyword arguments passed to `add_card()` (e.g. `collapsed`, `menu`) |

Link types:

| Type | Icon | Click behaviour |
|---|---|---|
| `'image'` | Thumbnail (natural aspect ratio) | Opens lightbox modal |
| `'data_sheet'` | `fa-file-pdf` | Opens URL in new tab |
| `'product_page'` | `fa-globe` | Opens URL in new tab |
| `'other'` | `fa-link` | Opens URL in new tab |

All tiles are 120px height. Image thumbnails preserve their aspect ratio using `object-fit: contain`. Icon tiles (data sheet, product page, other) are 120x120px squares with the icon and name label.

If a link dict includes an `'edit_url'` key, a small edit button appears in the top-right corner of that tile on hover. Clicking it navigates to the edit URL without triggering the tile's own click action.

Returns `None` if `links` is empty (no card rendered).

### Image Gallery Card

`add_image_gallery_card()` is a convenience wrapper around `add_link_gallery_card()` for image-only galleries:

```python
images = [
    {'url': 'https://example.com/front.jpg', 'name': 'Front View'},
    {'url': 'https://example.com/side.jpg', 'name': 'Side View', 'edit_url': '/images/2/edit/'},  # edit button on hover
    {'url': 'https://example.com/detail.jpg'},  # name is optional
]
card = self.add_image_gallery_card(images, card_name='photos', title='Product Photos')
```

Features:
- **Thumbnails**: 120px-height tiles preserving image aspect ratio
- **Lightbox**: Click any thumbnail to open a Bootstrap modal with the full-size image
- **Navigation**: Prev/next buttons when multiple images exist
- **Multiple galleries**: Each card gets a unique ID, so multiple gallery cards on one page work independently

Full example — a product detail view with a links gallery alongside other cards:

```python
from cards.standard import CardMixin
from django.views.generic import DetailView

class ProductDetailView(CardMixin, DetailView):
    model = Product
    template_name = 'products/detail.html'

    def setup_cards(self):
        # Main details card
        card = self.add_card('details', title='Product Details', details_object=self.object)
        card.add_rows('name', 'sku', 'description', 'price')

        # Links gallery from related model
        product_links = self.object.links.all()
        links = [{'url': l.url, 'name': l.name, 'type': l.link_type} for l in product_links]
        gallery = self.add_link_gallery_card(links, card_name='links', title='Links')

        # Layout: details on the left, gallery on the right
        self.add_card_group('details', div_css_class='col-6 float-left')
        right_cards = [gallery] if gallery else []
        self.add_card_group(*right_cards, div_css_class='col-6 float-right')
```

---

## Tiles

`add_tiles()` renders a card's body as a grid of small tiles, one per object -- the shape
`add_link_gallery_card()` draws for links, for anything else. Where `add_entry`/`add_rows` give
label/value rows, this gives a wrapping row of small bordered cards, each with a heading, an
optional subheading, a few meta lines, an optional image, an optional badge and an edit pencil.

```python
from cards.standard import CardMixin, Tile

card = self.add_card('colours', title='Colours & Finishes')
card.add_tiles(
    [
        Tile(
            key=f'colour_finish_{cf.pk}',     # stable id -> the tile's DOM id
            heading=cf.colour.name,           # escaped
            subheading=cf.finish.name,
            meta=[cf.price_display, ('none in stock', 'text-danger')],
            image_url=cf.finish.image.url,
            badge='Default' if cf.is_default else None,
            edit_url=f"javascript:django_modal.show_modal('colour_finish_modal-{cf.pk}')",
            tooltip=f'{cf.colour.name} / {cf.finish.name}',
            css_class='my-app-colour-tile',   # for anything only this page styles
        )
        for cf in colour_finishes
    ],
    empty_message='No colours or finishes yet. Use Add above to add one.',
    width='150px',
)
```

`Tile` fields -- every one but `key` is optional, and a tile draws only the parts it was given:

| Field | Purpose |
| --- | --- |
| `key` | Stable identifier; becomes the tile's DOM id, so it must be unique on the page |
| `heading` | The tile's first line. **Escaped** |
| `heading_html` | The first line as markup, for a heading that is built rather than written. **Rendered as-is** -- see below |
| `subheading` | A second, quieter line. **Escaped** |
| `meta` | Short lines under the heading: a string, or a `(text, css_class)` pair. **Escaped** |
| `image_url` | A 64px thumbnail under the meta lines |
| `badge` | A flag drawn last, in a Bootstrap badge. **Escaped** |
| `edit_url` | Where the pencil in the top-right corner goes. No pencil without one |
| `tooltip` | The tile's `title`, and its image's alt text. **Escaped** |
| `css_class` | Extra classes on the tile |

`add_tiles()` parameters:

| Parameter | Type | Default | Description |
| --- | --- | --- | --- |
| `tiles` | list | required | `Tile` objects, or dicts of the same fields |
| `empty_message` | str | `None` | Shown in place of the grid when there are no tiles. Nothing is drawn without one |
| `width` | str | `150px` | The width of one tile, as a CSS length |

Called more than once, `add_tiles()` adds to the grid rather than replacing it, and it returns
the card so the call can be chained onto `add_card()`.

### Escaping

Everything a tile shows is escaped **except `heading_html`**, which is the one field rendered as
markup. It is named for what it does so that putting user-entered text through it is an obvious
mistake rather than an invisible one:

```python
from django.utils.html import format_html

# The colour's name written on the colour itself. format_html escapes the name, so only the
# span built on this line is markup -- the tenant's text stays text.
Tile(key=f'colour_{colour.pk}',
     heading_html=format_html('<span style="background:{}">{}</span>', colour.hex, colour.name))
```

Pass anything that came from a form or a model field through `heading` instead, where the
template escapes it. Markup handed to `heading` is not quietly rendered: the escaping follows the
field the caller chose, not the shape of the value.

### Styling

The chrome lives in the package stylesheet (`cards/css/cards.css`, injected once per request when
a card that needs it renders), under `.django-card__tile*`. It reuses `image_gallery`'s numbers --
8px gap and padding, `#dee2e6` on `#f8f9fa`, `.8` hover opacity -- so a page carrying both reads
as one family. Two differences from the gallery's own tiles are deliberate:

- **The pencil is always shown, not revealed on hover.** It is what tells a reader the tile can be
  edited at all.
- **Tile width is per card.** `width='190px'` for a tile holding a two-part measurement that must
  not wrap; the default 150px otherwise. It reaches the CSS as the `--django-card-tile-width`
  custom property on the container.

Retint the chrome without out-specifying anything by setting the custom properties it reads:
`--django-card-border-color`, `--django-card-tile-bg`, `--django-card-tile-subheading-color`,
`--django-card-tile-meta-color`.

See the `/tiles/` page in `django_examples` for a tile with every field, one with none of the
optional ones, both shapes above, and the empty state.

---

## Linked Datatables

Display multiple datatables side by side with drill-down filtering. Clicking a row in one table filters the next table in the chain. Supports any number of linked tables.

### Basic Setup

```python
from cards.base import CARD_TYPE_LINKED_DATATABLES
from cards.standard import CardMixin
from django.views.generic import TemplateView

class CompanyDrilldown(CardMixin, TemplateView):
    template_name = 'myapp/cards.html'
    ajax_commands = ['datatable', 'row']

    def setup_cards(self):
        self.add_linked_datatables_card(
            card_name='drilldown',
            title='Company Drilldown',
            datatables=[
                {'id': 'ld_categories', 'model': CompanyCategory, 'title': 'Categories'},
                {'id': 'ld_companies', 'model': Company, 'title': 'Companies',
                 'linked_field': 'company_category_id'},
                {'id': 'ld_people', 'model': Person, 'title': 'People',
                 'linked_field': 'company_id'},
            ]
        )
        self.add_card_group('drilldown', div_css_class='col-12')

    def setup_table_ld_categories(self, table, details_object):
        table.ajax_data = True
        table.add_columns('id', 'name')

    def setup_table_ld_companies(self, table, details_object):
        table.ajax_data = True
        table.add_columns('id', 'name', 'importance')

    def setup_table_ld_people(self, table, details_object):
        table.ajax_data = True
        table.add_columns('id', 'first_name', 'surname')
```

### How It Works

1. The first table loads data normally via AJAX
2. Subsequent tables start empty — they load when a row is selected in the previous table
3. Selecting a row sends the `linked_field` value as a filter to the next table's AJAX query
4. The first row is auto-selected on load (except for the last table)
5. Selecting a different row clears and reloads all downstream tables

### `add_linked_datatables_card()` Parameters

| Parameter | Type | Default | Description |
|---|---|---|---|
| `card_name` | str | — | Unique card identifier |
| `title` | str | `''` | Card header title |
| `datatables` | list[dict] | — | List of datatable configuration dicts (see below) |
| `**kwargs` | | | Additional keyword arguments passed to `add_card()` |

### Datatable Config Dict

| Key | Type | Required | Description |
|---|---|---|---|
| `id` | str | Yes | Unique table identifier (also used for `setup_table_<id>()` method name) |
| `model` | Model | Yes | Django model class for the table |
| `title` | str | No | Display title above the table (defaults to id with underscores replaced) |
| `linked_field` | str | No | Field name to filter by when the previous table's row is selected |
| `css_class` | str | No | Additional CSS class for the table's panel container |
| `row_link` | str | No | URL name for navigation when a row is clicked (last table only) |
| `menu` | list | No | Menu items (e.g. buttons) displayed next to the table title |

### Row Link on Final Table

Add a `row_link` to the last table to navigate to another page when a row is clicked:

```python
from django_datatables.helpers import DUMMY_ID

datatables=[
    {'id': 'ld_categories', 'model': CompanyCategory, 'title': 'Categories'},
    {'id': 'ld_companies', 'model': Company, 'title': 'Companies',
     'linked_field': 'company_category_id'},
    {'id': 'ld_people', 'model': Person, 'title': 'People',
     'linked_field': 'company_id',
     'row_link': f'admin:cards_examples_person_change,{DUMMY_ID}'},
]
```

The `row_link` uses the same format as django-datatables row links. `DUMMY_ID` is replaced with the actual row ID on click. Navigation only happens on a real click — auto-selection does not trigger it.

### Custom Query Methods

For complex filtering (e.g. where the linked field isn't a direct FK), define a `get_<table_id>_query` method:

```python
def get_ld_payments_query(self, table, **kwargs):
    person_id = self.request.POST.get('linked_filter_value')
    if person_id:
        company = Person.objects.get(id=person_id).company
        table.filter['company_id'] = company.id
    return table.get_query(**kwargs)
```

When a custom query method exists, the automatic `linked_field` filter is skipped.

### Features

- **Keyboard navigation**: Arrow keys to move between rows (up/down) and tables (left/right)
- **Arrow indicator**: A `▶` column is automatically added to tables that link to the next table
- **Toggle deselect**: Clicking a selected row deselects it and clears downstream tables
- **Auto-select**: The first row is automatically selected on load for all tables except the last

### Table Setup

Each table is configured via a `setup_table_<id>()` method, just like standard datatable cards:

```python
def setup_table_ld_companies(self, table, details_object):
    table.ajax_data = True
    table.add_columns('id', 'name', 'importance')
```

Set `table.ajax_data = True` on all tables — the linked datatables system handles starting subsequent tables empty and loading them when needed.

---

## Accordion

Collapsible accordion panels where each panel can contain a different card type (standard detail cards, datatables, HTML cards, etc.).

### Basic Setup

```python
from cards.base import CARD_TYPE_DATATABLE
from cards.standard import CardMixin
from django.views.generic import TemplateView

class AccordionView(CardMixin, TemplateView):
    template_name = 'myapp/cards.html'
    ajax_commands = ['datatable', 'row']

    def setup_datatable_cards(self):
        self.add_card('acc_companies',
                      group_type=CARD_TYPE_DATATABLE,
                      datatable_model=Company)

    def setup_table_acc_companies(self, table, details_object):
        table.ajax_data = True
        table.add_columns('id', 'name', 'importance')

    def setup_cards(self):
        # Standard detail card
        detail_card = self.add_card(title='Overview')
        detail_card.add_entry(label='Total', value=Company.objects.count())

        # Datatable card
        companies_card = self.cards['acc_companies']

        # HTML card
        notes_card = self.add_card(title='Notes')
        notes_card.add_entry(label='Info', value='Any card type works inside an accordion.')

        self.add_accordion_card(
            card_name='my_accordion',
            title='Accordion Example',
            panels=[
                {'title': 'Overview', 'card': detail_card, 'icon': 'fas fa-chart-bar',
                 'expanded': True},
                {'title': 'Companies', 'card': companies_card, 'icon': 'fas fa-building'},
                {'title': 'Notes', 'card': notes_card, 'icon': 'fas fa-sticky-note'},
            ]
        )

        self.add_card_group('my_accordion', div_css_class='col-12')
```

### `add_accordion_card()` Parameters

| Parameter | Type | Default | Description |
|---|---|---|---|
| `card_name` | str | — | Unique card identifier |
| `title` | str | `''` | Card header title |
| `panels` | list[dict] | — | List of panel configuration dicts (see below) |
| `multi_open` | bool | `False` | Allow multiple panels to be open simultaneously |
| `full_height` | bool | `False` | Stretch accordion to fill remaining viewport height |
| `min_height` | str | `'300px'` | Minimum height when `full_height` is enabled |
| `**kwargs` | | | Additional keyword arguments passed to `add_card()` |

### Panel Config Dict

| Key | Type | Default | Description |
|---|---|---|---|
| `title` | str | `'Panel N'` | Panel header text |
| `card` | CardBase | — | Card object to render inside the panel |
| `icon` | str | `None` | Font Awesome class for the panel header icon |
| `expanded` | bool | `False` | Whether the panel starts expanded |
| `ajax_load` | bool | `False` | Load panel content via AJAX on first expand |
| `header_css_class` | str | `''` | CSS class for the panel header |
| `id` | str | auto | Custom panel ID (auto-generated if omitted) |

### Single Open (Default)

By default, only one panel can be open at a time. Opening a panel collapses the others:

```python
self.add_accordion_card(
    card_name='single',
    title='Single Open',
    panels=[
        {'title': 'Panel A', 'card': card_a, 'expanded': True},
        {'title': 'Panel B', 'card': card_b},
        {'title': 'Panel C', 'card': card_c},
    ]
)
```

### Multi Open

Set `multi_open=True` to allow multiple panels open simultaneously:

```python
self.add_accordion_card(
    card_name='multi',
    title='Multi Open',
    multi_open=True,
    panels=[
        {'title': 'Details', 'card': card1, 'expanded': True},
        {'title': 'Status', 'card': card2, 'expanded': True},
        {'title': 'Notes', 'card': card3},
    ]
)
```

### AJAX Lazy Loading

Set `ajax_load=True` on a panel to defer loading its content until the panel is first expanded. This is useful for panels with expensive queries or large datatables:

```python
self.add_accordion_card(
    card_name='lazy',
    title='Lazy Loading',
    panels=[
        {'title': 'Summary', 'card': summary_card, 'expanded': True},
        {'title': 'People', 'card': people_card, 'ajax_load': True},
        {'title': 'Notes', 'card': notes_card, 'ajax_load': True},
    ]
)
```

AJAX-loaded panels show a spinner placeholder until the content is fetched. Content is only loaded once — subsequent expand/collapse toggles use the cached content.

### Full Height

Set `full_height=True` to make the accordion stretch to fill the remaining viewport height. The expanded panel's content area becomes scrollable. A minimum height prevents the accordion from being too small on short viewports:

```python
self.add_accordion_card(
    card_name='sidebar',
    title='Navigation',
    full_height=True,
    min_height='400px',
    panels=[
        {'title': 'Items', 'card': items_card, 'expanded': True},
        {'title': 'Settings', 'card': settings_card},
    ]
)
```

This works well for sidebar layouts where the accordion sits alongside other content (see the Layout Example below).

### Panel Icons and Styles

Each panel can have an icon and custom header styling:

```python
panels=[
    {'title': 'Overview', 'card': card1, 'icon': 'fas fa-info-circle'},
    {'title': 'Settings', 'card': card2, 'icon': 'fas fa-cog',
     'header_css_class': 'bg-light'},
]
```

### Nesting Card Types

Any card type can be placed inside an accordion panel. The panel automatically hides the nested card's own header to avoid visual duplication:

- Standard detail cards
- Datatable cards (define in `setup_datatable_cards()`, reference via `self.cards['name']`)
- HTML cards
- Image gallery cards
- Tile cards
- Other card types

### Layout Example — Accordion with Side Panel

Use card groups to place an accordion alongside other cards:

```python
class DashboardView(CardMixin, TemplateView):
    template_name = 'myapp/cards.html'
    ajax_commands = ['datatable', 'row']

    def setup_datatable_cards(self):
        self.add_card('acc_people',
                      group_type=CARD_TYPE_DATATABLE,
                      datatable_model=Person)

    def setup_table_acc_people(self, table, details_object):
        table.ajax_data = True
        table.add_columns('id', 'first_name', 'surname')

    def setup_cards(self):
        # Cards for accordion panels
        summary_card = self.add_card(title='Summary')
        summary_card.add_entry(label='Companies', value=Company.objects.count())
        summary_card.add_entry(label='People', value=Person.objects.count())

        people_card = self.cards['acc_people']

        notes_card = self.add_card(title='Notes')
        notes_card.add_entry(label='Tip', value='Accordion on the left, details on the right.')

        # Accordion card — fills remaining page height
        self.add_accordion_card(
            card_name='nav_accordion',
            title='Navigation',
            full_height=True,
            panels=[
                {'title': 'Summary', 'card': summary_card, 'icon': 'fas fa-chart-bar',
                 'expanded': True},
                {'title': 'People', 'card': people_card, 'icon': 'fas fa-users'},
                {'title': 'Notes', 'card': notes_card, 'icon': 'fas fa-sticky-note'},
            ]
        )

        # Detail card on the right
        detail_card = self.add_card('details', title='Details', details_object=self.get_object())
        detail_card.add_rows('name', 'active', 'importance')
        detail_card.add_entry(field='company_category__name', label='Category')

        # Layout: accordion col-4 left, details col-8 right
        self.add_card_group('nav_accordion', div_css_class='col-4 float-left')
        self.add_card_group('details', div_css_class='col-8 float-left')
```

---

## Panel Layout

A CSS Grid-based panel layout system for building IDE-style interfaces with resizable and collapsible regions. Supports nested splits, tabbed content, header toolbars, linked datatables across regions, and persistent state via localStorage.

### Basic Setup

```python
from cards.standard import CardMixin
from django.views.generic import TemplateView

class DashboardView(CardMixin, TemplateView):
    template_name = 'myapp/cards.html'

    def setup_cards(self):
        layout = self.add_panel_layout(min_height='500px')
        root = layout.root

        sidebar = root.add_region('sidebar', size='250px', collapsible=True, min_size=150,
                                  title='Navigation')
        main_region = root.add_region('main', size='1fr', min_size=200,
                                      title='Dashboard')

        nav_card = self.add_card(title='Navigation')
        nav_card.add_rows(
            {'label': 'Dashboard', 'value': 'Overview of all data'},
            {'label': 'Companies', 'value': 'Manage company records'},
        )
        sidebar.add_card(nav_card)

        main_card = self.add_card(title='Dashboard')
        main_card.add_rows(
            {'label': 'Info', 'value': 'Drag the splitter bar to resize panels'},
        )
        main_region.add_card(main_card)

        self.add_card_group(layout.render(), div_css_class='col-12')
```

### `add_panel_layout()` Parameters

| Parameter | Type | Default | Description |
|---|---|---|---|
| `card_name` | str | `'panel_layout'` | Unique card identifier |
| `layout_id` | str | auto | DOM id for the layout container |
| `direction` | str | `'horizontal'` | Root split direction — `'horizontal'` or `'vertical'` |
| `resizable` | bool | `True` | Whether panels can be resized by dragging |
| `full_height` | bool | `True` | Automatically size the layout to fill viewport height (see note below) |
| `min_height` | str | `'400px'` | CSS min-height value |
| `css_class` | str | `''` | Extra CSS classes on the layout container |
| `css_style` | str | `''` | Extra inline styles on the layout container |
| `persist` | bool | `True` | Save/restore panel sizes and collapse state to localStorage |

With `full_height=True` the layout is sized to what is left of the viewport below its own top edge,
less whatever comes *after* it on the page — a breadcrumb, a footer — so those land on the bottom edge
instead of being pushed past it. A page with nothing below the layout is sized as it always was. The
measurement is re-taken on resize and again on load, when the heights below have settled.

### Splits

Splits arrange child items (regions or nested splits) horizontally or vertically using CSS Grid. Splits can be nested to create complex layouts.

```python
# Nested layout: sidebar + right side split into top and bottom
layout = self.add_panel_layout(min_height='550px')
root = layout.root

sidebar = root.add_region('sidebar', size='250px', collapsible=True)

right = root.add_split(direction='vertical')
top_region = right.add_region('top', size='200px')
bottom_region = right.add_region('bottom', size='1fr')
```

`add_split()` parameters:

| Parameter | Type | Default | Description |
|---|---|---|---|
| `direction` | str | opposite of parent | `'horizontal'` or `'vertical'` |
| `sizes` | list | `None` | Explicit CSS grid track sizes |
| `resizable` | bool | `True` | Show splitter bars between children |
| `name` | str | `None` | Identifier (required if collapsible) |
| `collapsible` | bool | `False` | Allow collapsing the whole split |
| `collapsed` | bool | `False` | Start collapsed |
| `title` | str | `None` | Title for the collapse toolbar |

### Regions

Regions are the leaf containers that hold cards, tabs, or nested layouts. Each region occupies a cell in its parent split.

```python
region = root.add_region(
    'editor', size='1fr',
    title='Editor',
    menu=[MenuItem(...)],           # right side of title bar
    toolbar=[MenuItem(...)],        # separate bar below title
    collapsible=True,
    min_size=200,
)
region.add_card(content_card)
```

`add_region()` parameters:

| Parameter | Type | Default | Description |
|---|---|---|---|
| `name` | str | — | Unique region identifier |
| `size` | str | `'1fr'` | CSS grid track size (e.g. `'250px'`, `'1fr'`, `'auto'`) |
| `min_size` | int | `None` | Minimum pixel size during drag resize |
| `max_size` | int | `None` | Maximum pixel size during drag resize |
| `collapsible` | bool | `False` | Allow collapsing |
| `collapsed` | bool | `False` | Start collapsed |
| `collapse_direction` | str | auto | Override chevron direction (`'horizontal'` or `'vertical'`) |
| `overflow` | str | `'auto'` | CSS overflow value |
| `title` | str | `None` | Title in the header toolbar |
| `menu` | list | `None` | Menu items in the header toolbar (right-aligned by default) |
| `menu_align` | str | `'right'` | Alignment of menu — `'right'` or `'left'` |
| `toolbar` | list | `None` | Menu items for a separate left-aligned bar below the header |

Visual structure of a region:

```
┌─────────────────────────────────────┐
│ Header toolbar  (title + menu)      │  ← title/menu params
├─────────────────────────────────────┤
│ Menu bar  (left-aligned buttons)    │  ← toolbar param
├─────────────────────────────────────┤
│ Tab bar  (tabs + per-tab menu)      │  ← add_tab()
├─────────────────────────────────────┤
│                                     │
│ Content area  (cards)               │  ← add_card() / tab.add_card()
│                                     │
└─────────────────────────────────────┘
```

All layers are optional. A region with just `add_card()` has only the content area.

### Tabs

Add tabbed content within a region. Each tab can have its own cards and per-tab menu:

```python
from django_menus.menu import AjaxButtonMenuItem

region = root.add_region('main', size='1fr', title='Data')

companies_menu = [
    AjaxButtonMenuItem(button_name='add_company', menu_display='',
                       font_awesome='fas fa-plus',
                       css_classes='btn btn-sm btn-outline-success'),
]
companies_tab = region.add_tab('companies', title='Companies',
                                icon='fas fa-building', active=True,
                                menu=companies_menu)
companies_tab.add_card(companies_datatable)

people_tab = region.add_tab('people', title='People',
                             icon='fas fa-users')
people_tab.add_card(people_datatable)
```

`add_tab()` parameters:

| Parameter | Type | Default | Description |
|---|---|---|---|
| `name` | str | — | Unique tab identifier |
| `title` | str | — | Display label on the tab |
| `icon` | str | `None` | Font Awesome class for a tab icon |
| `active` | bool | `False` | Whether this tab is initially selected (first tab is active by default) |
| `menu` | list | `None` | Per-tab menu items shown to the right of the tab bar when active |

### Linked Datatables in Panel Layout

Place linked datatables in separate resizable regions:

```python
class DrilldownView(CardMixin, TemplateView):
    template_name = 'myapp/cards.html'
    ajax_commands = ['datatable', 'row']

    def setup_datatable_cards(self):
        layout = self.add_panel_layout(min_height='550px')
        root = layout.root

        cat_region = root.add_region('categories', size='1fr', title='Categories')
        comp_region = root.add_region('companies', size='1fr', title='Companies')
        people_region = root.add_region('people', size='1fr', title='People')

        cat_card = self.add_card('pl_categories', group_type=CARD_TYPE_DATATABLE,
                                  datatable_model=CompanyCategory)
        cat_region.add_card(cat_card)

        comp_card = self.add_card('pl_companies', group_type=CARD_TYPE_DATATABLE,
                                   datatable_model=Company)
        comp_region.add_card(comp_card)

        people_card = self.add_card('pl_people', group_type=CARD_TYPE_DATATABLE,
                                     datatable_model=Person)
        people_region.add_card(people_card)

        layout.linked_tables = [
            {'table_id': 'pl_categories'},
            {'table_id': 'pl_companies', 'linked_field': 'company_category_id'},
            {'table_id': 'pl_people', 'linked_field': 'company_id'},
        ]

        self.add_card_group(layout.render(), div_css_class='col-12')

    def setup_table_pl_categories(self, table, details_object):
        table.ajax_data = True
        table.add_columns('id', 'name')

    def setup_table_pl_companies(self, table, details_object):
        table.ajax_data = False
        table.table_data = []
        table.add_columns('id', 'name', 'importance')

    def setup_table_pl_people(self, table, details_object):
        table.ajax_data = False
        table.table_data = []
        table.add_columns('id', 'first_name', 'surname')
```

Set `linked_tables` on the layout as a list of dicts. Subsequent tables start empty and load when a row is selected in the previous table.

### Holy Grail Layout

A classic header/sidebar/content/sidebar/footer layout using nested splits:

```python
layout = self.add_panel_layout(min_height='600px', direction='vertical')
root = layout.root

header = root.add_region('header', size='auto')
middle = root.add_split(direction='horizontal')
footer = root.add_region('footer', size='auto')

left = middle.add_region('left_nav', size='200px', collapsible=True)
centre = middle.add_region('content', size='1fr')
right = middle.add_region('aside', size='220px', collapsible=True)
```

### Features

- **Drag-resizable** splitter bars between regions
- **Collapsible** regions with animated chevron icons
- **Tabbed content** within regions with per-tab menus
- **Header toolbars** and separate menu bars on regions
- **Linked datatables** across regions
- **Accordion cards** that fill region height
- **Nested splits** for complex multi-pane layouts
- **Full-height mode** fills viewport minus surrounding content
- **Persistent state** via localStorage (sizes + collapse state)

---

## Treegrid

A Fancytree-based hierarchical grid with lazy-loaded children, optional inline editing, per-column filters, row selection, and custom toolbar buttons.

### Basic Setup

```python
from cards.standard import CardMixin
from django.views.generic import TemplateView
from django.urls import reverse

class OrgTreeView(CardMixin, TemplateView):
    template_name = 'myapp/cards.html'

    def setup_cards(self):
        self.add_treegrid_card(
            card_name='org_tree',
            title='Organisation Tree',
            treegrid_columns=[
                {'title': 'Name',     'field': 'title',    'width': '50%'},
                {'title': 'Category', 'field': 'category', 'width': '30%'},
                {'title': 'People',   'field': 'people_count', 'width': '20%'},
            ],
            treegrid_icon_map={
                'category': 'fas fa-layer-group',
                'company':  'fas fa-building',
                'person':   'fas fa-user',
            },
        )
        self.add_card_group('org_tree', div_css_class='col-12')

    def get_treegrid_org_tree_data(self, parent=None):
        if parent is None:
            return [{'title': 'Root', 'key': 'root_1', 'folder': True, 'lazy': True,
                     'data': {'type': 'category', 'category': '', 'people_count': 5}}]
        # Return children for the given parent key
        return []
```

### `add_treegrid_card()` Parameters

| Parameter | Type | Default | Description |
|---|---|---|---|
| `card_name` | str | `None` | Unique card identifier |
| `title` | str | `None` | Card header title. If `None`, no header is shown |
| `treegrid_columns` | list | `[]` | Column definitions (see below) |
| `treegrid_data_url` | str | `''` | URL for a separate data endpoint (GET with `?parent=` param) |
| `treegrid_static_data` | list | `None` | Inline static tree data (no AJAX) |
| `treegrid_read_only` | bool | `True` | Disable inline editing |
| `treegrid_height` | str | `'600px'` | CSS max-height of the scrollable table area |
| `treegrid_indentation` | int | `20` | Pixels of indentation per tree level |
| `treegrid_icon_map` | dict | `{}` | Maps node `data.type` → FontAwesome class |
| `treegrid_show_filter` | bool | `True` | Show the Expand All / Collapse All / global filter toolbar |
| `treegrid_show_search` | bool | `None` | Show the search box on its own. `None` follows `treegrid_show_filter` |
| `treegrid_show_expand_buttons` | bool | `None` | Show Expand All / Collapse All on their own. `None` follows `treegrid_show_filter` |
| `treegrid_auto_hide_expand_buttons` | bool | `False` | Hide Expand All / Collapse All while nothing in the tree can expand |
| `treegrid_expand_all` | bool | `False` | Expand all root nodes on initial load |
| `treegrid_show_column_filters` | bool | `False` | Show per-column filter inputs in header row |
| `treegrid_filter_auto_expand` | bool | `False` | Expand collapsed branches to reveal filter matches (before 1.5.0 this was accepted but had no effect) |
| `treegrid_toolbar` | list | `[]` | Custom toolbar buttons (see below) |
| `treegrid_toolbar_after` | list | `[]` | Additional buttons rendered after the checkbox controls |
| `treegrid_toolbar_end` | list | `[]` | Additional buttons rendered at the end of the toolbar, after Expand All / Collapse All |
| `treegrid_submit_label` | str | `'Submit Selected'` | Label for the submit button when `treegrid_checkbox=True` |
| `treegrid_header_rows` | list | `[]` | Multi-row header definitions (for colspan/rowspan headers) |
| `treegrid_node_column` | int | `0` | Column index that displays the tree node title and expand icon |
| `treegrid_save_mode` | str | `'auto'` | `'auto'` = save on each change; `'batch'` = collect then save all |
| `treegrid_checkbox` | bool | `False` | Enable row selection checkboxes |
| `treegrid_checkbox_column` | int | `0` | Column index for the checkbox (default: leftmost extra column) |
| `treegrid_show_select_buttons` | bool | `True` | Show the Select All / Deselect All buttons a checkbox grid renders |
| `treegrid_show_submit_button` | bool | `True` | Show the submit button a checkbox grid renders (see `treegrid_submit_label`) |
| `treegrid_show_select_count` | bool | `True` | Show the "N selected" counter a checkbox grid renders |
| `treegrid_context_menu` | list | `None` | Context menu items on right-click (MenuItems or dicts) |
| `treegrid_resizable` | bool | `False` | Allow dragging column borders to resize columns |
| `treegrid_pagination` | bool | `False` | Enable client-side pagination of root-level nodes |
| `treegrid_page_size` | int | `50` | Rows per page when `treegrid_pagination=True` |
| `column_search` | bool | `False` | Alias for `treegrid_show_column_filters` (card-level parameter) |
| `**kwargs` | | | Additional parameters passed to `add_card()` (e.g. `collapsed`, `menu`, `footer`) |

### Column Definitions

Each entry in `treegrid_columns` is a dict:

| Key | Type | Default | Description |
|---|---|---|---|
| `title` | str | — | Column header text |
| `field` | str | — | Key in node `data` dict. Use `'title'` for the node title column |
| `width` | str | `None` | CSS column width (e.g. `'30%'`, `'120px'`) |
| `css_class` | str | `None` | Classes added to every cell in the column **and** to its header, e.g. `'text-center'` |
| `header_css_class` | str | `None` | Classes for the header only. Overrides `css_class` there |
| `type` | str | `None` | `'boolean'`, `'html'`, `'actions'`, `'checkbox'`, `'select'` |
| `editable` | bool | `False` | Enable inline editing for this column |
| `inline` | bool | `True` | `False` = open a popup widget instead of editing in-place |
| `options` | list | `None` | For `type='select'`: list of `{'value': ..., 'label': ...}` dicts |
| `visible_for` | list | `None` | Only show a widget for node types in this list (e.g. `['item', 'group']`) |
| `filter` | bool | `True` | Set `False` to disable the column's filter input when column filters are on |
| `filter_options` | any | `None` | Controls the filter widget (see Column Filters below) |

### Data Modes

There are three ways to provide node data:

**1. Self-dispatch (default)** — define a `get_treegrid_<card_name>_data(parent=None)` method on the view:

```python
def get_treegrid_my_tree_data(self, parent=None):
    if parent is None:
        # Return root nodes
        return [{'title': 'Root', 'key': 'root_1', 'folder': True, 'lazy': True,
                 'data': {'type': 'category'}}]
    # Return children for parent key
    if parent.startswith('root_'):
        return [{'title': 'Child', 'key': 'child_1', 'folder': False,
                 'data': {'type': 'item'}}]
    return []
```

**2. Separate URL** — pass `treegrid_data_url` to a view that accepts `?parent=<key>`:

```python
from django.views import View
from django.http import JsonResponse

class MyTreeData(View):
    def get(self, request):
        parent_key = request.GET.get('parent')
        return JsonResponse(get_nodes(parent_key), safe=False)
```

```python
self.add_treegrid_card(
    card_name='my_tree',
    treegrid_data_url=reverse('myapp:tree_data'),
    ...
)
```

**3. Static data** — pass `treegrid_static_data` as a Python list with nested `children`:

```python
data = [
    {
        'title': 'Fruits', 'key': 'fruits', 'folder': True,
        'data': {'type': 'group', 'price': ''},
        'children': [
            {'title': 'Apple', 'key': 'apple', 'folder': False,
             'data': {'type': 'item', 'price': '1.20'}},
            {'title': 'Banana', 'key': 'banana', 'folder': False,
             'data': {'type': 'item', 'price': '0.80'}},
        ],
    },
]
self.add_treegrid_card(card_name='my_tree', treegrid_static_data=data, ...)
```

### Node Data Format

Each node returned by your data source is a dict:

| Key           | Required | Description                                                         |
|---------------|---|---------------------------------------------------------------------|
| `title`       | Yes | The text displayed in the tree node column                          |
| `key`         | Yes | Unique string identifier — passed back as `parent` to load children |
| `folder`      | Yes | `True` if this node can have children                               |
| `lazy`        | No | `True` to defer loading children until the node is expanded         |
| `data`        | Yes | Dict of column field values. Include `'type'` for icon mapping      |
| `childCount`  | No | Badge shown next to the node title (e.g. `5`)                       |
| `children`    | No | Inline pre-loaded children (for static data or eager loading)       |
| `disableEdit` | No | Disable edit for this row when editing is enabled.                  |

### Styled Cells and Rows

Include styling keys in `node.data` to colour individual cells or entire rows:

```python
{
    'title': 'Company A',
    'key': 'company_1',
    'data': {
        'type': 'company',
        'status': 'Critical',
        'amount': '99500',
        # Per-cell: field__bg, field__color, field__class
        'amount__bg': '#d4edda',
        'amount__color': '#28a745',
        'amount__class': 'font-weight-bold fw-bold',
        # Per-row: _row_bg, _row_color, _row_class
        '_row_bg': '#fff3cd',
        '_row_class': 'my-group-row',
    }
}
```

`_row_class` is handed to Fancytree as an `extraClasses` entry as well as applied to the `<tr>`, so
it survives the row re-renders Fancytree does on expand, collapse, activate and focus. A class that
should apply to a whole column, rather than to the rows that happen to carry a key for it, belongs on
the column instead (`css_class`, see Column Definitions) — that puts it on the header too.

You can also apply styles server-side after a save using the helper methods:

```python
def button_my_tree_save(self, **kwargs):
    key = kwargs.get('key')
    # Style a single cell
    self.treegrid_style_cell('my_tree', key, 'amount', bg='#d4edda', color='#28a745')
    # Style an entire row
    self.treegrid_style_row('my_tree', key, bg='#fff3cd')
    # Update a cell value
    self.treegrid_update_cell('my_tree', key, 'total', '99,500')
    return self.command_response()
```

### Inline Editing

Set `treegrid_read_only=False` and mark individual columns `editable=True`. When a cell is double-clicked an input opens. On blur/enter the value is posted to `button_<card_name>_save`:

```python
def setup_cards(self):
    self.add_treegrid_card(
        card_name='edit_tree',
        treegrid_read_only=False,
        treegrid_columns=[
            {'title': 'Name',  'field': 'title',      'width': '50%', 'editable': True},
            {'title': 'Score', 'field': 'score',       'width': '25%', 'editable': True},
            {'title': 'Grade', 'field': 'grade',       'width': '25%', 'editable': True,
             'type': 'select',
             'options': [
                 {'value': 'A', 'label': 'A — Excellent'},
                 {'value': 'B', 'label': 'B — Good'},
                 {'value': 'C', 'label': 'C — Pass'},
             ]},
        ],
        ...
    )

def button_edit_tree_save(self, **kwargs):
    key   = kwargs.get('key')       # node key, e.g. 'company_42'
    field = kwargs.get('field')     # column field name, e.g. 'score'
    value = kwargs.get('value')     # new value as string
    # row_<field> keys also available for the full current row state
    # ... persist to database ...
    return self.command_response()
```

**Widget types:**

| `type` | Behaviour |
|---|---|
| *(omitted)* | Plain text input |
| `'select'` | Dropdown — provide `options` list of `{'value': ..., 'label': ...}` |
| `'checkbox'` | Toggle boolean. Use `'inline': False` for a modal-style popup |
| `'boolean'` | Read-only checkmark/cross display (not editable) |
| `'html'` | Raw HTML rendered in the cell (not editable) |
| `'actions'` | Action button column — define `actions` list of `{'name', 'icon', 'title'}` |

Use `'inline': False` to force a confirmation popup rather than in-place editing:

```python
{'title': 'Active', 'field': 'is_active', 'type': 'checkbox', 'editable': True, 'inline': False}
```

Use `'visible_for'` to only show a widget on certain node types (useful for mixed-type trees):

```python
{'title': 'Primary', 'field': 'primary', 'type': 'checkbox', 'editable': True, 'visible_for': ['item']}
```

### Batch Save

Set `treegrid_save_mode='batch'` to collect all changes locally and post them all at once when the user clicks the Save button:

```python
self.add_treegrid_card(
    card_name='batch_tree',
    treegrid_read_only=False,
    treegrid_save_mode='batch',
    ...
)

def button_batch_tree_batch_save(self, **kwargs):
    import json
    changes = json.loads(kwargs.get('changes', '[]'))
    # Each change: {'key': '...', 'field': '...', 'value': '...'}
    for change in changes:
        ...
    return self.command_response()
```

### Toolbar Buttons

Add custom buttons to the toolbar above the tree:

```python
self.add_treegrid_card(
    card_name='my_tree',
    treegrid_toolbar=[
        {'name': 'new_group',   'label': 'New Group',   'icon': 'fas fa-folder-plus'},
        {'name': 'new_company', 'label': 'New Company', 'icon': 'fas fa-plus'},
    ],
    ...
)

def button_my_tree_new_group(self, **kwargs):
    return self.command_response(toast_commands(header='New Group', text='...'))

def button_my_tree_new_company(self, **kwargs):
    return self.command_response(toast_commands(header='New Company', text='...'))
```

Each button dict takes:

| Key | Type | Default | Description |
|---|---|---|---|
| `name` | str | — | Identifier; posted back to `button_<card_name>_<name>()` |
| `label` | str | — | Button text |
| `icon` | str | `None` | FontAwesome class |
| `button_class` | str | `'btn-outline-secondary'` | Bootstrap button class (`treegrid_toolbar_after` / `treegrid_toolbar_end` only) |
| `needs_selection` | int/bool | `None` | Render disabled until this many rows are ticked (`True` means 1) |

There are three toolbar slots. `treegrid_toolbar` renders first, `treegrid_toolbar_after` follows
the selection controls, and `treegrid_toolbar_end` renders last — after the card's own Expand All /
Collapse All, which is where a button that acts on the grid usually belongs:

```python
self.add_treegrid_card(
    card_name='my_tree',
    treegrid_checkbox=True,
    treegrid_toolbar_end=[
        # Dead until two rows are ticked: the row's own action covers the single case.
        {'name': 'delete', 'label': 'Delete', 'icon': 'fas fa-trash',
         'button_class': 'btn-danger', 'needs_selection': 2},
    ],
    ...
)
```

### Row Selection (Checkboxes)

Set `treegrid_checkbox=True` to add a checkbox column. Select All / Deselect All buttons appear in the toolbar. Clicking Submit posts the selected keys:

```python
self.add_treegrid_card(
    card_name='select_tree',
    treegrid_checkbox=True,
    treegrid_submit_label='Apply Selection',
    ...
)

def button_select_tree_selected(self, **kwargs):
    import json
    keys = json.loads(kwargs.get('selected_keys', '[]'))
    # keys is a list of selected node key strings
    return self.command_response()
```

Each of those three controls can be left out where the grid does not want it — a grid whose rows
carry their own actions, or one where "select every row" is not something anyone means to press:

```python
self.add_treegrid_card(
    card_name='pick_tree',
    treegrid_checkbox=True,
    treegrid_show_select_buttons=False,   # no Select All / Deselect All
    treegrid_show_submit_button=False,    # no Submit Selected
    treegrid_show_select_count=False,     # no "3 selected"
    ...
)
```

### Search and Expand Buttons

`treegrid_show_filter` switches the search box and the Expand All / Collapse All pair together.
`treegrid_show_search` and `treegrid_show_expand_buttons` split that switch when a grid wants one
without the other — a page of rows the reader can see all of needs no search box, but still needs to
open and close its groups:

```python
self.add_treegrid_card(
    card_name='usage_tree',
    treegrid_show_search=False,               # no search box
    treegrid_show_expand_buttons=True,        # keep Expand All / Collapse All
    ...
)
```

`treegrid_auto_hide_expand_buttons=True` hides those two buttons while nothing in the tree can
expand, so a flat list does not carry a pair of controls that do nothing. It is decided from the
tree on every load and lazy load, not from a flag saying the grid is flat, so a list that starts
nesting gets them back on its own.

### Pagination

Set `treegrid_pagination=True` to page through root-level nodes in the browser. All root nodes load in a single request; children still lazy-load normally:

```python
self.add_treegrid_card(
    card_name='paginated_tree',
    treegrid_pagination=True,
    treegrid_page_size=10,
    treegrid_checkbox=True,   # checkbox + pagination work together
    ...
)
```

### Column Filters

Enable per-column filter inputs in the header row with either `treegrid_show_column_filters=True` or `column_search=True`:

```python
self.add_treegrid_card(
    card_name='filter_tree',
    treegrid_show_column_filters=True,
    treegrid_columns=[
        {'title': 'Name',     'field': 'title'},
        {'title': 'Category', 'field': 'category'},
        {'title': 'Status',   'field': 'status', 'type': 'boolean'},
        {'title': 'Actions',  'field': '',        'type': 'actions'},  # no filter
    ],
    ...
)
```

The default filter widget per column is:
- **`type='boolean'`** → Yes/No `<select>`
- **`type='actions'`** → no filter
- **everything else** → text `<input>`

Override the filter widget using `filter_options` on a column definition:

**Explicit list of options:**
```python
{'title': 'Category', 'field': 'category',
 'filter_options': ['Technology', 'Finance', 'Healthcare']}
```

**Options with separate display label and search value:**
```python
{'title': 'Category', 'field': 'category',
 'filter_options': [
     {'label': 'Technology',  'value': 'tech'},
     {'label': 'Finance',     'value': 'fin'},
     {'label': 'Healthcare',  'value': 'health'},
 ]}
```

The `value` is matched against the node data; the `label` is what appears in the dropdown.

**Auto-generated from loaded data:**
```python
{'title': 'Category', 'field': 'category', 'filter_options': True}
```

Setting `filter_options=True` makes the select automatically populate with all unique values found across loaded nodes. The options refresh after each lazy-load expansion. This works with both paginated and non-paginated modes.

Disable filtering on a specific column with `'filter': False`:
```python
{'title': 'Notes', 'field': 'notes', 'filter': False}
```

### Side-Panel JS Filters

Pass `treegrid_js_filters` to render a pivot-style filter panel to the left of the tree. Each filter shows the unique values for a field as checkboxes with occurrence counts; unchecking a value hides matching rows:

```python
self.add_treegrid_card(
    card_name='my_tree',
    treegrid_js_filters=[
        {'field': 'category', 'title': 'Category'},
        {'field': 'status',   'title': 'Status'},
    ],
    ...
)
```

- All values are checked (shown) by default.
- Click **All** in a filter block header to re-check every value in that block.
- Works in both paginated and non-paginated modes.
- In paginated mode the panel filters combine with the toolbar search box — both must match for a row to appear.

### Multi-Row Headers

Use `treegrid_header_rows` to build colspan/rowspan headers. Define a list of rows, each a list of cell dicts:

```python
self.add_treegrid_card(
    card_name='colspan_tree',
    treegrid_node_column=4,   # which column holds the tree expand icon
    treegrid_header_rows=[
        [
            {'title': 'Status',  'rowspan': 2},
            {'title': 'Selections', 'colspan': 3, 'css_class': 'text-center'},
            {'title': 'Name',    'rowspan': 2},
            {'title': 'Options', 'colspan': 2, 'css_class': 'text-center'},
        ],
        [
            {'title': 'Primary'},
            {'title': 'Optional'},
            {'title': 'Ignore'},
            {'title': 'Category'},
            {'title': 'Type'},
        ],
    ],
    treegrid_columns=[
        {'title': 'Status',   'field': 'status'},
        {'title': 'Primary',  'field': 'primary',  'editable': True, 'type': 'checkbox'},
        {'title': 'Optional', 'field': 'optional', 'editable': True, 'type': 'checkbox'},
        {'title': 'Ignore',   'field': 'ignore',   'editable': True, 'type': 'checkbox'},
        {'title': 'Name',     'field': 'title'},
        {'title': 'Category', 'field': 'category'},
        {'title': 'Type',     'field': 'window_type'},
    ],
    ...
)
```

When using colspan headers, the `treegrid_node_column` must be set to the correct 0-based index of the column that should show the tree icon.

### Context Menu

Add a right-click context menu with `treegrid_context_menu`. Mix `MenuItem`/`DividerItem` objects (rendered server-side) with plain dicts (handled by JS):

```python
from django_menus.menu import MenuItem, DividerItem

self.add_treegrid_card(
    card_name='adv_tree',
    treegrid_context_menu=[
        MenuItem(url='myapp:detail', menu_display='View Details',
                 font_awesome='fas fa-external-link-alt', link_type=MenuItem.HREF),
        DividerItem(),
        {'name': 'add_child',  'label': 'Add Child',  'icon': 'fas fa-plus'},
        {'name': 'delete',     'label': 'Delete',      'icon': 'fas fa-trash text-danger'},
    ],
    ...
)

def button_adv_tree_context(self, **kwargs):
    action = kwargs.get('action')   # e.g. 'add_child' or 'delete'
    key    = kwargs.get('key')      # the right-clicked node key
    if action == 'delete':
        self.treegrid_remove_node('adv_tree', key)
    elif action == 'add_child':
        self.treegrid_add_node('adv_tree', parent_key=key, node_data={
            'title': 'New Node', 'key': 'new_1', 'folder': False,
            'data': {'type': 'item'},
        })
    return self.command_response()
```

### Server-Side Node Manipulation

After a save or button press, manipulate tree nodes from the view:

```python
# Update a cell value
self.treegrid_update_cell(card_name, key, field, value)

# Style a cell (bg and/or color)
self.treegrid_style_cell(card_name, key, field, bg='#d4edda', color='#28a745')

# Style a whole row
self.treegrid_style_row(card_name, key, bg='#fff3cd', color='')

# Add a node (mode='child', 'before', or 'after')
self.treegrid_add_node(card_name, parent_key=key, node_data={...}, mode='child')

# Add a root node
self.treegrid_add_node(card_name, parent_key=None, node_data={...})

# Remove a node
self.treegrid_remove_node(card_name, key)

# Move a node
self.treegrid_move_node(card_name, key, target_key, mode='child')

# Force a full data reload
return self.treegrid_reload_response(card_name)
```

### Remembering State
If `ajax_helpers` are being used inside a project, the treegrid state can be remembered when reloading the tree. For example:

```python
class SomeView:
    def get_tree_grid_card(self):
        ...
        return tree_grid_card # The treegrid card object
    
    # AJAX helpers button or other method
    def button_refresh_tree_grid(self):
        card = self.get_tree_grid_card()
        self.add_command('treegrid_snapshot_state', card=card.code)
        selector = 'some_selector_for_parent_div_of_treegrid'
        return self.command_response('html', selector=selector, html=card.render())


```
NOTES: 
- This does not work with lazy treegrids, only static data treegrids. For lazy, the `self.add_command('reload_treegrid', card=card_name)` command can be used.
- This also does not work with paginated treegrids currently. 

### Several Treegrids on One Page

The stylesheet and the grid's behaviour -- about 110 KiB together -- are the same for every
treegrid, so they are emitted **once per page** rather than once per card. Each card emits only
its own config object and one call into the shared behaviour, which is what keeps two grids on
a page independent: the per-card state lives in the closure that call makes.

Nothing needs configuring for this, and each card still renders as it always did. Two things
are worth knowing:

- **"The page" means the request cycle.** A card reloaded over ajax, or a modal body fetched on
  its own, is a request of its own and carries the shared half again -- harmlessly, since the
  browser already has it and the second copy does not overwrite the first.
- **A card rendered outside a request cycle carries its own copy.** Building cards in a
  management command or straight from `RequestFactory` fires no request signals, so there is no
  page to mark; the shared half is emitted per card, as it was before. Rendering the same cards
  through a served request -- including through a form widget that builds its own card mixin,
  and so has no request to hand its card -- shares one copy.

Overriding `cards/standard/treegrid.html` in a project means keeping its last three lines,
which are what fetch the shared half and this card's config:

```django
{% treegrid_shared_assets %}
{% include 'cards/standard/_treegrid_init.html' %}
{% include 'cards/standard/_reload_script.html' %}
```

`treegrid_shared_assets` comes from `{% load django_cards_tags %}`.

#### Upgrading from 1.4.x

Nothing changes for a project using the package templates as-is, or for one that wraps
`cards/standard/treegrid.html` with `{% include %}`. A project that **copied** either
template to override it must update its copy, because `_treegrid_script.html` no longer
initialises a grid by itself -- it defines the shared behaviour once, and something has to
call it. A stale copy renders grids that never come to life, so each shape logs a console
error rather than failing silently:

- A copied **`treegrid.html`** includes `_treegrid_script.html` per card and never includes
  `_treegrid_init.html`, so the shared behaviour lands with nothing to call it. The console
  error says so; the fix is the three closing lines shown above.
- A copied **`_treegrid_script.html`** overrides the shared half with per-card 1.4.x code
  that defines no shared behaviour, so every card's config queues and nothing drains it. The
  console error names the affected card codes; the fix is to drop the override, or to
  re-copy it from 1.5.

The same queued-config error covers an overridden `treegrid.html` that kept the
`_treegrid_init.html` include but dropped the `{% treegrid_shared_assets %}` tag.

An override of `_treegrid_script.html` or `_treegrid_css.html` must also stay free of
`{% trans %}` and of anything else that varies per request: the shared half is rendered
without a context and cached for the life of the process.

---

## Iframe Card

Embed external URLs or inline HTML content in a sandboxed iframe.

### Basic Usage

```python
from cards.standard import CardMixin
from django.views.generic import TemplateView

class IframeView(CardMixin, TemplateView):
    template_name = 'myapp/cards.html'

    def setup_cards(self):
        # Load an external URL
        self.add_iframe_card(
            card_name='docs',
            title='Documentation',
            iframe_url='https://docs.djangoproject.com/',
        )

        # Inline HTML content (e.g. Three.js, D3, charts)
        self.add_iframe_card(
            card_name='scene',
            title='3D Viewer',
            iframe_srcdoc='<html><body><h1>Hello</h1></body></html>',
            iframe_height='500px',
        )

        self.add_card_group('docs', div_css_class='col-6 float-left')
        self.add_card_group('scene', div_css_class='col-6 float-left')
```

### `add_iframe_card()` Parameters

| Parameter | Type | Default | Description |
|---|---|---|---|
| `card_name` | str | `None` | Unique card identifier |
| `title` | str | `None` | Card header title. If `None`, no header is shown |
| `iframe_url` | str | `''` | URL to load in the iframe |
| `iframe_srcdoc` | str | `''` | Inline HTML content for the iframe |
| `iframe_height` | str | `'400px'` | CSS height of the iframe. Use `'100%'` inside panel layout regions |
| `iframe_sandbox` | str | `'allow-scripts allow-same-origin'` | Sandbox attribute value |
| `**kwargs` | | | Additional keyword arguments passed to `add_card()` |

### Inside Panel Layout

Iframe cards work well inside panel layout regions. Use `iframe_height='100%'` to fill the region:

```python
def setup_cards(self):
    layout = self.add_panel_layout(min_height='550px')
    root = layout.root

    sidebar = root.add_region('sidebar', size='280px', collapsible=True)
    right = root.add_split(direction='vertical')
    top = right.add_region('top', size='1fr')
    bottom = right.add_region('bottom', size='1fr')

    info_card = self.add_card(title='Info')
    info_card.add_entry(label='Top', value='Three.js demo')
    sidebar.add_card(info_card)

    threejs_card = self.add_iframe_card(
        card_name='threejs',
        title='Three.js Demo',
        iframe_srcdoc='<html>...</html>',
        iframe_height='100%',
    )
    top.add_card(threejs_card)

    chart_card = self.add_iframe_card(
        card_name='chart',
        title='Chart',
        iframe_srcdoc='<html>...</html>',
        iframe_height='100%',
    )
    bottom.add_card(chart_card)

    self.add_card_group(layout.render(), div_css_class='col-12')
```

---

## License

MIT
