Fixed double-click handling for column width reset

This commit is contained in:
2026-02-23 22:56:48 +01:00
parent 2af43f357d
commit 9fe511c97b
4 changed files with 272 additions and 33 deletions

View File

@@ -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**`<div id="X"><div id="X" hx-on::...>new</div></div>` (duplicate ID)
- `outerHTML`: replaces **element**`<div id="X" hx-on::...>new</div>` (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 `<div id="X"><div id="X">...</div></div>` (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 ## Complete Control Template
```python ```python
@@ -378,7 +496,7 @@ class Commands(BaseCommands):
return Command("Toggle", return Command("Toggle",
"Toggle visibility", "Toggle visibility",
self._owner, self._owner,
self._owner.toggle self._owner.handle_toggle
).htmx(target=f"#{self._id}") ).htmx(target=f"#{self._id}")
@@ -391,7 +509,7 @@ class MyControl(MultipleInstance):
logger.debug(f"MyControl created with id={self._id}") logger.debug(f"MyControl created with id={self._id}")
def toggle(self): def handle_toggle(self):
self._state.visible = not self._state.visible self._state.visible = not self._state.visible
return self return self

View File

@@ -6,6 +6,32 @@ function initDataGrid(gridId) {
updateDatagridSelection(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. * Initialize DataGrid hover effects using event delegation.
@@ -457,17 +483,21 @@ function makeDatagridColumnsResizable(datagridId) {
function onDoubleClick(event) { function onDoubleClick(event) {
const handle = event.target; const handle = event.target;
const cell = handle.parentElement; const cell = handle.parentElement;
const colIndex = cell.getAttribute('data-col'); const colId = cell.getAttribute('data-col');
const cells = table.querySelectorAll(`.dt2-cell[data-col="${colIndex}"]`); const resetCommandId = handle.dataset.resetCommandId;
// Reset column width // Call server command to calculate and apply optimal width
cells.forEach(cell => { if (resetCommandId) {
cell.style.width = ''; // Use CSS default width htmx.ajax('POST', '/myfasthtml/commands', {
}); headers: {"Content-Type": "application/x-www-form-urlencoded"},
target: '#th_' + datagridId,
// Emit reset event swap: 'outerHTML',
const resetEvent = new CustomEvent('columnReset', {detail: {colIndex}}); values: {
table.dispatchEvent(resetEvent); c_id: resetCommandId,
col_id: colId
}
});
}
} }
} }

View File

@@ -33,7 +33,7 @@ from myfasthtml.core.formatting.dsl.parser import DSLParser
from myfasthtml.core.formatting.engine import FormattingEngine from myfasthtml.core.formatting.engine import FormattingEngine
from myfasthtml.core.instances import MultipleInstance from myfasthtml.core.instances import MultipleInstance
from myfasthtml.core.optimized_ft import OptimizedDiv 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.carbon import row, column, grid
from myfasthtml.icons.fluent import checkbox_unchecked16_regular from myfasthtml.icons.fluent import checkbox_unchecked16_regular
from myfasthtml.icons.fluent_p2 import checkbox_checked16_regular, column_edit20_regular from myfasthtml.icons.fluent_p2 import checkbox_checked16_regular, column_edit20_regular
@@ -102,6 +102,9 @@ class DatagridSettings(DbObject):
class DatagridStore(DbObject): class DatagridStore(DbObject):
"""
Store Dataframes
"""
def __init__(self, owner, save_state): def __init__(self, owner, save_state):
with self.initializing(): with self.initializing():
super().__init__(owner, name=f"{owner.get_id()}#df", save_state=save_state) super().__init__(owner, name=f"{owner.get_id()}#df", save_state=save_state)
@@ -128,7 +131,7 @@ class Commands(BaseCommands):
return Command("SetColumnWidth", return Command("SetColumnWidth",
"Set column width after resize", "Set column width after resize",
self._owner, self._owner,
self._owner.set_column_width self._owner.handle_set_column_width
).htmx(target=None) ).htmx(target=None)
def move_column(self): def move_column(self):
@@ -138,6 +141,13 @@ class Commands(BaseCommands):
self._owner.move_column self._owner.move_column
).htmx(target=None) ).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): def filter(self):
return Command("Filter", return Command("Filter",
"Filter Grid", "Filter Grid",
@@ -522,7 +532,7 @@ class DataGrid(MultipleInstance):
row_dict[col_def.col_id] = default_value row_dict[col_def.col_id] = default_value
self._df_store.save() 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.""" """Update column width after resize. Called via Command from JS."""
logger.debug(f"set_column_width: {col_id=} {width=}") logger.debug(f"set_column_width: {col_id=} {width=}")
for col in self._state.columns: for col in self._state.columns:
@@ -562,6 +572,68 @@ class DataGrid(MultipleInstance):
self._state.save() 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): def filter(self):
logger.debug("filter") logger.debug("filter")
self._state.filtered[FILTER_INPUT_CID] = self._datagrid_filter.get_query() self._state.filtered[FILTER_INPUT_CID] = self._datagrid_filter.get_query()
@@ -648,6 +720,7 @@ class DataGrid(MultipleInstance):
def mk_headers(self): def mk_headers(self):
resize_cmd = self.commands.set_column_width() resize_cmd = self.commands.set_column_width()
move_cmd = self.commands.move_column() move_cmd = self.commands.move_column()
reset_cmd = self.commands.reset_column_width()
def _mk_header_name(col_def: DataGridColumnState): def _mk_header_name(col_def: DataGridColumnState):
return Div( return Div(
@@ -663,7 +736,7 @@ class DataGrid(MultipleInstance):
return Div( return Div(
_mk_header_name(col_def), _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;", style=f"width:{col_def.width}px;",
data_col=col_def.col_id, data_col=col_def.col_id,
data_tooltip=col_def.title, data_tooltip=col_def.title,
@@ -749,7 +822,7 @@ class DataGrid(MultipleInstance):
style, formatted_value = self._formatting_engine.apply_format(rules, value, row_data) style, formatted_value = self._formatting_engine.apply_format(rules, value, row_data)
# Use formatted value or convert to string # 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) # OPTIMIZATION: Only escape if necessary (check for HTML special chars with pre-compiled regex)
if _HTML_SPECIAL_CHARS_REGEX.search(value_str): if _HTML_SPECIAL_CHARS_REGEX.search(value_str):
@@ -993,11 +1066,11 @@ class DataGrid(MultipleInstance):
style="height: 100%; grid-template-rows: auto 1fr;" 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 fragment: cell | body | table | header
:param redraw_scrollbars: :param kwargs: Additional parameters for specific fragments (col_id, optimal_width for header)
:return: :return:
""" """
res = [] res = []
@@ -1016,6 +1089,18 @@ class DataGrid(MultipleInstance):
table.attrs.update(extra_attr) table.attrs.update(extra_attr)
res.append(table) 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()) res.append(self.mk_selection_manager())
return tuple(res) return tuple(res)

View File

@@ -2,6 +2,7 @@ import importlib
import logging import logging
import re import re
import pandas as pd
from bs4 import Tag from bs4 import Tag
from fastcore.xml import FT from fastcore.xml import FT
from fasthtml.fastapp import fast_app from fasthtml.fastapp import fast_app
@@ -10,9 +11,9 @@ from rich.table import Table
from starlette.routing import Mount from starlette.routing import Mount
from myfasthtml.core.constants import Routes, ROUTE_ROOT 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.dsl.types import Position
from myfasthtml.core.dsls import DslsManager from myfasthtml.core.dsls import DslsManager
from myfasthtml.core.dsl.exceptions import DSLSyntaxError
from myfasthtml.test.MyFT import MyFT from myfasthtml.test.MyFT import MyFT
utils_app, utils_rt = fast_app() utils_app, utils_rt = fast_app()
@@ -181,6 +182,10 @@ def is_datalist(elt):
return False return False
def is_null(v):
return v is None or pd.isna(v) or pd.isnull(v)
def quoted_str(s): def quoted_str(s):
if s is None: if s is None:
return "None" return "None"
@@ -335,6 +340,7 @@ def make_unique_safe_id(s: str | None, existing_ids: list):
i += 1 i += 1
return res return res
def get_class(qualified_class_name: str): def get_class(qualified_class_name: str):
""" """
Dynamically loads and returns a class type from its fully qualified name. Dynamically loads and returns a class type from its fully qualified name.