Metadata-Version: 2.4
Name: django-cards
Version: 1.4.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.6
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)

## 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) |
| `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)
```

### 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 |
| `details_class` | `'col-sm-7 col-md-8 col-lg-9 float-left'` | CSS class for details panel |

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')
```

---

## 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
- 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 |
| `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 |

### 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_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_toolbar` | list | `[]` | Custom toolbar buttons (see below) |
| `treegrid_toolbar_after` | list | `[]` | Additional buttons rendered after the checkbox controls |
| `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_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'`) |
| `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
        'amount__bg': '#d4edda',
        'amount__color': '#28a745',
        # Per-row: _row_bg, _row_color
        '_row_bg': '#fff3cd',
    }
}
```

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='...'))
```

### 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()
```

### 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)
```

---

## 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
