diff --git a/.claude/commands/developer-control.md b/.claude/commands/developer-control.md
index 9035fda..f40b114 100644
--- a/.claude/commands/developer-control.md
+++ b/.claude/commands/developer-control.md
@@ -340,6 +340,124 @@ def mk_content(self):
---
+### DEV-CONTROL-20: HTMX Ajax Requests
+
+**Always specify a `target` in HTMX ajax requests.**
+
+```javascript
+// ❌ INCORRECT: Without target, HTMX doesn't know where to swap the response
+htmx.ajax('POST', '/url', {
+ values: {param: value}
+});
+
+// ✅ CORRECT: Explicitly specify the target
+htmx.ajax('POST', '/url', {
+ target: '#element-id',
+ values: {param: value}
+});
+```
+
+**Exception:** Response contains elements with `hx-swap-oob="true"`.
+
+---
+
+### DEV-CONTROL-21: HTMX Swap Modes and Event Listeners
+
+**`hx-on::after-settle` only works when the swapped element replaces the target (`outerHTML`).**
+
+```javascript
+// ❌ INCORRECT: innerHTML (default) nests the returned element
+// The hx-on::after-settle attribute on the returned element is never processed
+htmx.ajax('POST', '/url', {
+ target: '#my-element'
+ // swap: 'innerHTML' is the default
+});
+
+// ✅ CORRECT: outerHTML replaces the entire element
+// The hx-on::after-settle attribute on the returned element works
+htmx.ajax('POST', '/url', {
+ target: '#my-element',
+ swap: 'outerHTML'
+});
+```
+
+**Why:**
+- `innerHTML`: replaces **content** → `
` (duplicate ID)
+- `outerHTML`: replaces **element** → `new
` (correct)
+
+---
+
+### DEV-CONTROL-22: Reinitializing Event Listeners
+
+**After an HTMX swap, event listeners attached via JavaScript are lost and must be reinitialized.**
+
+**Recommended pattern:**
+
+1. Create a reusable initialization function:
+```javascript
+function initMyControl(controlId) {
+ const element = document.getElementById(controlId);
+ // Attach event listeners
+ element.addEventListener('click', handleClick);
+}
+```
+
+2. Call this function after swap via `hx-on::after-settle`:
+```python
+extra_attr = {
+ "hx-on::after-settle": f"initMyControl('{self._id}');"
+}
+element.attrs.update(extra_attr)
+```
+
+**Alternative:** Use event delegation on a stable parent element.
+
+---
+
+### DEV-CONTROL-23: Avoiding Duplicate IDs with HTMX
+
+**If the element returned by the server has the same ID as the HTMX target, use `swap: 'outerHTML'`.**
+
+```python
+# Server returns an element with id="my-element"
+def render_partial(self):
+ return Div(id="my-element", ...) # Same ID as target
+
+# JavaScript must use outerHTML
+htmx.ajax('POST', '/url', {
+ target: '#my-element',
+ swap: 'outerHTML' # ✅ Replaces the entire element
+});
+```
+
+**Why:** `innerHTML` would create `` (invalid duplicate ID).
+
+---
+
+### DEV-CONTROL-24: Pattern extra_attr for HTMX
+
+**Use the `extra_attr` pattern to add post-swap behaviors.**
+
+```python
+def render_partial(self, fragment="default"):
+ extra_attr = {
+ "hx-on::after-settle": f"initControl('{self._id}');",
+ # Other HTMX attributes if needed
+ }
+
+ element = self.mk_element()
+ element.attrs.update(extra_attr)
+ return element
+```
+
+**Use cases:**
+- Reinitialize event listeners
+- Execute animations
+- Update other DOM elements
+- Logging or tracking events
+
+---
+
## Complete Control Template
```python
@@ -378,7 +496,7 @@ class Commands(BaseCommands):
return Command("Toggle",
"Toggle visibility",
self._owner,
- self._owner.toggle
+ self._owner.handle_toggle
).htmx(target=f"#{self._id}")
@@ -391,7 +509,7 @@ class MyControl(MultipleInstance):
logger.debug(f"MyControl created with id={self._id}")
- def toggle(self):
+ def handle_toggle(self):
self._state.visible = not self._state.visible
return self
diff --git a/src/myfasthtml/assets/datagrid/datagrid.js b/src/myfasthtml/assets/datagrid/datagrid.js
index de25794..9e9a891 100644
--- a/src/myfasthtml/assets/datagrid/datagrid.js
+++ b/src/myfasthtml/assets/datagrid/datagrid.js
@@ -6,6 +6,32 @@ function initDataGrid(gridId) {
updateDatagridSelection(gridId)
}
+/**
+ * Set width for a column and reinitialize handlers.
+ * Updates all cells (header + body + footer) and reattaches event listeners.
+ *
+ * @param {string} gridId - The DataGrid instance ID
+ * @param {string} colId - The column ID
+ * @param {number} width - The new width in pixels
+ */
+function setColumnWidth(gridId, colId, width) {
+ const table = document.getElementById(`t_${gridId}`);
+ if (!table) {
+ console.error(`Table with id "t_${gridId}" not found.`);
+ return;
+ }
+
+ // Update all cells in the column (header + body + footer)
+ const cells = table.querySelectorAll(`.dt2-cell[data-col="${colId}"]`);
+ cells.forEach(cell => {
+ cell.style.width = `${width}px`;
+ });
+
+ // Reinitialize resize and move handlers (lost after header re-render)
+ makeDatagridColumnsResizable(gridId);
+ makeDatagridColumnsMovable(gridId);
+}
+
/**
* Initialize DataGrid hover effects using event delegation.
@@ -457,17 +483,21 @@ function makeDatagridColumnsResizable(datagridId) {
function onDoubleClick(event) {
const handle = event.target;
const cell = handle.parentElement;
- const colIndex = cell.getAttribute('data-col');
- const cells = table.querySelectorAll(`.dt2-cell[data-col="${colIndex}"]`);
+ const colId = cell.getAttribute('data-col');
+ const resetCommandId = handle.dataset.resetCommandId;
- // Reset column width
- cells.forEach(cell => {
- cell.style.width = ''; // Use CSS default width
- });
-
- // Emit reset event
- const resetEvent = new CustomEvent('columnReset', {detail: {colIndex}});
- table.dispatchEvent(resetEvent);
+ // Call server command to calculate and apply optimal width
+ if (resetCommandId) {
+ htmx.ajax('POST', '/myfasthtml/commands', {
+ headers: {"Content-Type": "application/x-www-form-urlencoded"},
+ target: '#th_' + datagridId,
+ swap: 'outerHTML',
+ values: {
+ c_id: resetCommandId,
+ col_id: colId
+ }
+ });
+ }
}
}
diff --git a/src/myfasthtml/controls/DataGrid.py b/src/myfasthtml/controls/DataGrid.py
index 83fb0f9..17c2b4a 100644
--- a/src/myfasthtml/controls/DataGrid.py
+++ b/src/myfasthtml/controls/DataGrid.py
@@ -33,7 +33,7 @@ from myfasthtml.core.formatting.dsl.parser import DSLParser
from myfasthtml.core.formatting.engine import FormattingEngine
from myfasthtml.core.instances import MultipleInstance
from myfasthtml.core.optimized_ft import OptimizedDiv
-from myfasthtml.core.utils import make_safe_id, merge_classes, make_unique_safe_id
+from myfasthtml.core.utils import make_safe_id, merge_classes, make_unique_safe_id, is_null
from myfasthtml.icons.carbon import row, column, grid
from myfasthtml.icons.fluent import checkbox_unchecked16_regular
from myfasthtml.icons.fluent_p2 import checkbox_checked16_regular, column_edit20_regular
@@ -102,6 +102,9 @@ class DatagridSettings(DbObject):
class DatagridStore(DbObject):
+ """
+ Store Dataframes
+ """
def __init__(self, owner, save_state):
with self.initializing():
super().__init__(owner, name=f"{owner.get_id()}#df", save_state=save_state)
@@ -128,7 +131,7 @@ class Commands(BaseCommands):
return Command("SetColumnWidth",
"Set column width after resize",
self._owner,
- self._owner.set_column_width
+ self._owner.handle_set_column_width
).htmx(target=None)
def move_column(self):
@@ -137,7 +140,14 @@ class Commands(BaseCommands):
self._owner,
self._owner.move_column
).htmx(target=None)
-
+
+ def reset_column_width(self):
+ return Command("ResetColumnWidth",
+ "Auto-size column to fit content",
+ self._owner,
+ self._owner.reset_column_width
+ ).htmx(target=f"#th_{self._id}")
+
def filter(self):
return Command("Filter",
"Filter Grid",
@@ -522,7 +532,7 @@ class DataGrid(MultipleInstance):
row_dict[col_def.col_id] = default_value
self._df_store.save()
- def set_column_width(self, col_id: str, width: str):
+ def handle_set_column_width(self, col_id: str, width: str):
"""Update column width after resize. Called via Command from JS."""
logger.debug(f"set_column_width: {col_id=} {width=}")
for col in self._state.columns:
@@ -561,7 +571,69 @@ class DataGrid(MultipleInstance):
self._state.columns.insert(target_idx, col)
self._state.save()
-
+
+ def calculate_optimal_column_width(self, col_id: str) -> int:
+ """
+ Calculate optimal width for a column based on content.
+
+ Considers both the title length and the maximum data length in the column,
+ then applies a formula to estimate pixel width.
+
+ Args:
+ col_id: Column identifier
+
+ Returns:
+ Optimal width in pixels (between 50 and 500)
+ """
+ col_def = next((c for c in self._state.columns if c.col_id == col_id), None)
+ if not col_def:
+ logger.warning(f"calculate_optimal_column_width: column not found {col_id=}")
+ return 150 # default width
+
+ # Title length
+ title_length = len(col_def.title)
+
+ # Max data length
+ max_data_length = 0
+ if col_id in self._df_store.ns_fast_access:
+ col_array = self._df_store.ns_fast_access[col_id]
+ if col_array is not None and len(col_array) > 0:
+ max_data_length = max(len(str(v)) for v in col_array)
+
+ # Calculate width (8px per char + 30px padding)
+ max_length = max(title_length, max_data_length)
+ optimal_width = max_length * 8 + 30
+
+ # Apply limits (50px min, 500px max)
+ return max(50, min(optimal_width, 500))
+
+ def reset_column_width(self, col_id: str):
+ """
+ Auto-size column to fit content. Called via Command from JS double-click.
+
+ Calculates the optimal width based on the longest content in the column
+ and applies it to all cells. Updates both the state and the visual display.
+
+ Args:
+ col_id: Column identifier to reset
+
+ Returns:
+ Updated header with script to update body cells
+ """
+ logger.debug(f"reset_column_width: {col_id=}")
+ optimal_width = self.calculate_optimal_column_width(col_id)
+
+ # Update and persist
+ for col in self._state.columns:
+ if col.col_id == col_id:
+ col.width = optimal_width
+ break
+
+ self._state.save()
+
+ # Return updated header with script to update body cells via after-settle
+ return self.render_partial("header", col_id=col_id, optimal_width=optimal_width)
+
def filter(self):
logger.debug("filter")
self._state.filtered[FILTER_INPUT_CID] = self._datagrid_filter.get_query()
@@ -648,7 +720,8 @@ class DataGrid(MultipleInstance):
def mk_headers(self):
resize_cmd = self.commands.set_column_width()
move_cmd = self.commands.move_column()
-
+ reset_cmd = self.commands.reset_column_width()
+
def _mk_header_name(col_def: DataGridColumnState):
return Div(
mk.label(col_def.title, icon=IconsHelper.get(col_def.type)),
@@ -656,14 +729,14 @@ class DataGrid(MultipleInstance):
cls="flex truncate cursor-default",
data_tooltip=col_def.title,
)
-
+
def _mk_header(col_def: DataGridColumnState):
if not col_def.visible:
return None
-
+
return Div(
_mk_header_name(col_def),
- Div(cls="dt2-resize-handle", data_command_id=resize_cmd.id),
+ Div(cls="dt2-resize-handle", data_command_id=resize_cmd.id, data_reset_command_id=reset_cmd.id),
style=f"width:{col_def.width}px;",
data_col=col_def.col_id,
data_tooltip=col_def.title,
@@ -749,7 +822,7 @@ class DataGrid(MultipleInstance):
style, formatted_value = self._formatting_engine.apply_format(rules, value, row_data)
# Use formatted value or convert to string
- value_str = formatted_value if formatted_value is not None else str(value)
+ value_str = formatted_value if formatted_value is not None else str(value) if not is_null(value) else ""
# OPTIMIZATION: Only escape if necessary (check for HTML special chars with pre-compiled regex)
if _HTML_SPECIAL_CHARS_REGEX.search(value_str):
@@ -993,31 +1066,43 @@ class DataGrid(MultipleInstance):
style="height: 100%; grid-template-rows: auto 1fr;"
)
- def render_partial(self, fragment="cell"):
+ def render_partial(self, fragment="cell", **kwargs):
"""
-
- :param fragment: cell | body
- :param redraw_scrollbars:
+
+ :param fragment: cell | body | table | header
+ :param kwargs: Additional parameters for specific fragments (col_id, optimal_width for header)
:return:
"""
res = []
-
+
extra_attr = {
"hx-on::after-settle": f"initDataGrid('{self._id}');",
}
-
+
if fragment == "body":
body_container = self.mk_body_wrapper()
body_container.attrs.update(extra_attr)
res.append(body_container)
-
+
elif fragment == "table":
table = self.mk_table()
table.attrs.update(extra_attr)
res.append(table)
-
+
+ elif fragment == "header":
+ col_id = kwargs.get("col_id")
+ optimal_width = kwargs.get("optimal_width")
+
+ header_extra_attr = {
+ "hx-on::after-settle": f"setColumnWidth('{self._id}', '{col_id}', '{optimal_width}');",
+ }
+
+ header = self.mk_headers()
+ header.attrs.update(header_extra_attr)
+ return header
+
res.append(self.mk_selection_manager())
-
+
return tuple(res)
def dispose(self):
diff --git a/src/myfasthtml/core/utils.py b/src/myfasthtml/core/utils.py
index b194124..65cd21a 100644
--- a/src/myfasthtml/core/utils.py
+++ b/src/myfasthtml/core/utils.py
@@ -2,6 +2,7 @@ import importlib
import logging
import re
+import pandas as pd
from bs4 import Tag
from fastcore.xml import FT
from fasthtml.fastapp import fast_app
@@ -10,9 +11,9 @@ from rich.table import Table
from starlette.routing import Mount
from myfasthtml.core.constants import Routes, ROUTE_ROOT
+from myfasthtml.core.dsl.exceptions import DSLSyntaxError
from myfasthtml.core.dsl.types import Position
from myfasthtml.core.dsls import DslsManager
-from myfasthtml.core.dsl.exceptions import DSLSyntaxError
from myfasthtml.test.MyFT import MyFT
utils_app, utils_rt = fast_app()
@@ -181,6 +182,10 @@ def is_datalist(elt):
return False
+def is_null(v):
+ return v is None or pd.isna(v) or pd.isnull(v)
+
+
def quoted_str(s):
if s is None:
return "None"
@@ -326,7 +331,7 @@ def make_safe_id(s: str | None):
def make_unique_safe_id(s: str | None, existing_ids: list):
if s is None:
return None
-
+
base = make_safe_id(s)
res = base
i = 1
@@ -335,6 +340,7 @@ def make_unique_safe_id(s: str | None, existing_ids: list):
i += 1
return res
+
def get_class(qualified_class_name: str):
"""
Dynamically loads and returns a class type from its fully qualified name.