I can click on the grid to select a cell

This commit is contained in:
2026-01-24 12:06:22 +01:00
parent 191ead1c89
commit ba2b6e672a
9 changed files with 1268 additions and 211 deletions

601
docs/DataGrid.md Normal file
View File

@@ -0,0 +1,601 @@
# DataGrid Component
## Introduction
The DataGrid component provides a high-performance tabular data display for your FastHTML application. It renders pandas
DataFrames with interactive features like column resizing, reordering, and filtering, all powered by HTMX for seamless
updates without page reloads.
**Key features:**
- Display tabular data from pandas DataFrames
- Resizable columns with drag handles
- Draggable columns for reordering
- Real-time filtering with search bar
- Virtual scrolling for large datasets (pagination with lazy loading)
- Custom scrollbars for consistent cross-browser appearance
- Optional state persistence per session
**Common use cases:**
- Data exploration and analysis dashboards
- Admin interfaces with tabular data
- Report viewers
- Database table browsers
- CSV/Excel file viewers
## Quick Start
Here's a minimal example showing a data table with a pandas DataFrame:
```python
import pandas as pd
from fasthtml.common import *
from myfasthtml.controls.DataGrid import DataGrid
from myfasthtml.core.instances import RootInstance
# Create sample data
df = pd.DataFrame({
"Name": ["Alice", "Bob", "Charlie", "Diana"],
"Age": [25, 30, 35, 28],
"City": ["Paris", "London", "Berlin", "Madrid"]
})
# Create root instance and data grid
root = RootInstance(session)
grid = DataGrid(parent=root)
grid.init_from_dataframe(df)
# Render the grid
return grid
```
This creates a complete data grid with:
- A header row with column names ("Name", "Age", "City")
- Data rows displaying the DataFrame content
- A search bar for filtering data
- Resizable column borders (drag to resize)
- Draggable columns (drag headers to reorder)
- Custom scrollbars for horizontal and vertical scrolling
**Note:** The DataGrid automatically detects column types (Text, Number, Bool, Datetime) from the DataFrame dtypes and
applies appropriate formatting.
## Basic Usage
### Visual Structure
The DataGrid component consists of a filter bar, a table with header/body/footer, and custom scrollbars:
```
┌────────────────────────────────────────────────────────────┐
│ Filter Bar │
│ ┌─────────────────────────────────────────────┐ ┌────┐ │
│ │ 🔍 Search... │ │ ✕ │ │
│ └─────────────────────────────────────────────┘ └────┘ │
├────────────────────────────────────────────────────────────┤
│ Header Row ▲ │
│ ┌──────────┬──────────┬──────────┬──────────┐ │ │
│ │ Column 1 │ Column 2 │ Column 3 │ Column 4 │ █ │
│ └──────────┴──────────┴──────────┴──────────┘ █ │
├────────────────────────────────────────────────────────█───┤
│ Body (scrollable) █ │
│ ┌──────────┬──────────┬──────────┬──────────┐ █ │
│ │ Value │ Value │ Value │ Value │ █ │
│ ├──────────┼──────────┼──────────┼──────────┤ │ │
│ │ Value │ Value │ Value │ Value │ │ │
│ ├──────────┼──────────┼──────────┼──────────┤ ▼ │
│ │ Value │ Value │ Value │ Value │ │
│ └──────────┴──────────┴──────────┴──────────┘ │
│ ◄═══════════════════════════════════════════════════════► │
└────────────────────────────────────────────────────────────┘
```
**Component details:**
| Element | Description |
|------------|-------------------------------------------------------|
| Filter bar | Search input with filter mode toggle and clear button |
| Header row | Column names with resize handles and drag support |
| Body | Scrollable data rows with virtual pagination |
| Scrollbars | Custom vertical and horizontal scrollbars |
### Creating a DataGrid
The DataGrid is a `MultipleInstance`, meaning you can create multiple independent grids in your application. Create it
by providing a parent instance:
```python
grid = DataGrid(parent=root_instance)
# Or with a custom ID
grid = DataGrid(parent=root_instance, _id="my-grid")
# Or with state persistence enabled
grid = DataGrid(parent=root_instance, save_state=True)
```
**Parameters:**
- `parent`: Parent instance (required)
- `_id` (str, optional): Custom identifier for the grid
- `save_state` (bool, optional): Enable state persistence (column widths, order, filters)
### Loading Data
Use the `init_from_dataframe()` method to load data into the grid:
```python
import pandas as pd
# Create a DataFrame
df = pd.DataFrame({
"Product": ["Laptop", "Phone", "Tablet"],
"Price": [999.99, 699.99, 449.99],
"In Stock": [True, False, True]
})
# Load into grid
grid.init_from_dataframe(df)
```
**Column type detection:**
The DataGrid automatically detects column types from pandas dtypes:
| pandas dtype | DataGrid type | Display |
|--------------------|---------------|-------------------------|
| `int64`, `float64` | Number | Right-aligned |
| `bool` | Bool | Checkbox icon |
| `datetime64` | Datetime | Formatted date |
| `object`, others | Text | Left-aligned, truncated |
### Row Index Column
By default, the DataGrid displays a row index column on the left. This can be useful for identifying rows:
```python
# Row index is enabled by default
grid._state.row_index = True
# To disable the row index column
grid._state.row_index = False
grid.init_from_dataframe(df)
```
## Column Features
### Resizing Columns
Users can resize columns by dragging the border between column headers:
- **Drag handle location**: Right edge of each column header
- **Minimum width**: 30 pixels
- **Persistence**: Resized widths are automatically saved when `save_state=True`
The resize interaction:
1. Hover over the right edge of a column header (cursor changes)
2. Click and drag to resize
3. Release to confirm the new width
4. Double-click to reset to default width
**Programmatic width control:**
```python
# Set a specific column width
for col in grid._state.columns:
if col.col_id == "my_column":
col.width = 200 # pixels
break
```
### Moving Columns
Users can reorder columns by dragging column headers:
1. Click and hold a column header
2. Drag to the desired position
3. Release to drop the column
The columns animate smoothly during the move, and other columns shift to accommodate the new position.
**Note:** Column order is persisted when `save_state=True`.
### Column Visibility
Columns can be hidden programmatically:
```python
# Hide a specific column
for col in grid._state.columns:
if col.col_id == "internal_id":
col.visible = False
break
```
Hidden columns are not rendered but remain in the state, allowing them to be shown again later.
## Filtering
### Using the Search Bar
The DataGrid includes a built-in search bar that filters rows in real-time:
```
┌─────────────────────────────────────────────┐ ┌────┐
│ 🔍 Search... │ │ ✕ │
└─────────────────────────────────────────────┘ └────┘
│ │
│ └── Clear button
└── Filter mode icon (click to cycle)
```
**How filtering works:**
1. Type in the search box
2. The grid filters rows where ANY visible column contains the search text
3. Matching text is highlighted in the results
4. Click the ✕ button to clear the filter
### Filter Modes
Click the filter icon to cycle through three modes:
| Mode | Icon | Description |
|------------|------|------------------------------------|
| **Filter** | 🔍 | Hides non-matching rows |
| **Search** | 🔎 | Highlights matches, shows all rows |
| **AI** | 🧠 | AI-powered search (future feature) |
The current mode affects how results are displayed:
- **Filter mode**: Only matching rows are shown
- **Search mode**: All rows shown, matches highlighted
## Advanced Features
### State Persistence
Enable state persistence to save user preferences across sessions:
```python
# Enable state persistence
grid = DataGrid(parent=root, save_state=True)
```
**What gets persisted:**
| State | Description |
|-------------------|---------------------------------|
| Column widths | User-resized column sizes |
| Column order | User-defined column arrangement |
| Column visibility | Which columns are shown/hidden |
| Sort order | Current sort configuration |
| Filter state | Active filters |
### Virtual Scrolling
For large datasets, the DataGrid uses virtual scrolling with lazy loading:
- Only a subset of rows (page) is rendered initially
- As the user scrolls down, more rows are loaded automatically
- Uses Intersection Observer API for efficient scroll detection
- Default page size: configurable via `DATAGRID_PAGE_SIZE`
This allows smooth performance even with thousands of rows.
### Text Size
Customize the text size for the grid body:
```python
# Available sizes: "xs", "sm", "md", "lg"
grid._settings.text_size = "sm" # default
```
### CSS Customization
The DataGrid uses CSS classes that you can customize:
| Class | Element |
|-----------------------------|-------------------------|
| `dt2-table-wrapper` | Root table container |
| `dt2-table` | Table element |
| `dt2-header-container` | Header wrapper |
| `dt2-body-container` | Scrollable body wrapper |
| `dt2-footer-container` | Footer wrapper |
| `dt2-row` | Table row |
| `dt2-cell` | Table cell |
| `dt2-resize-handle` | Column resize handle |
| `dt2-scrollbars-vertical` | Vertical scrollbar |
| `dt2-scrollbars-horizontal` | Horizontal scrollbar |
| `dt2-highlight-1` | Search match highlight |
**Example customization:**
```css
/* Change highlight color */
.dt2-highlight-1 {
background-color: #fef08a;
font-weight: bold;
}
/* Customize row hover */
.dt2-row:hover {
background-color: #f3f4f6;
}
/* Style the scrollbars */
.dt2-scrollbars-vertical,
.dt2-scrollbars-horizontal {
background-color: #3b82f6;
border-radius: 4px;
}
```
## Examples
### Example 1: Simple Data Table
A basic data table displaying product information:
```python
import pandas as pd
from fasthtml.common import *
from myfasthtml.controls.DataGrid import DataGrid
from myfasthtml.core.instances import RootInstance
# Sample product data
df = pd.DataFrame({
"Product": ["Laptop Pro", "Wireless Mouse", "USB-C Hub", "Monitor 27\"", "Keyboard"],
"Category": ["Computers", "Accessories", "Accessories", "Displays", "Accessories"],
"Price": [1299.99, 49.99, 79.99, 399.99, 129.99],
"In Stock": [True, True, False, True, True],
"Rating": [4.5, 4.2, 4.8, 4.6, 4.3]
})
# Create and configure grid
root = RootInstance(session)
grid = DataGrid(parent=root, _id="products-grid")
grid.init_from_dataframe(df)
# Render
return Div(
H1("Product Catalog"),
grid,
cls="p-4"
)
```
### Example 2: Large Dataset with Filtering
Handling a large dataset with virtual scrolling and filtering:
```python
import pandas as pd
import numpy as np
from fasthtml.common import *
from myfasthtml.controls.DataGrid import DataGrid
from myfasthtml.core.instances import RootInstance
# Generate large dataset (10,000 rows)
np.random.seed(42)
n_rows = 10000
df = pd.DataFrame({
"ID": range(1, n_rows + 1),
"Name": [f"Item_{i}" for i in range(n_rows)],
"Value": np.random.uniform(10, 1000, n_rows).round(2),
"Category": np.random.choice(["A", "B", "C", "D"], n_rows),
"Active": np.random.choice([True, False], n_rows),
"Created": pd.date_range("2024-01-01", periods=n_rows, freq="h")
})
# Create grid with state persistence
root = RootInstance(session)
grid = DataGrid(parent=root, _id="large-dataset", save_state=True)
grid.init_from_dataframe(df)
return Div(
H1("Large Dataset Explorer"),
P(f"Displaying {n_rows:,} rows with virtual scrolling"),
grid,
cls="p-4",
style="height: 100vh;"
)
```
**Note:** Virtual scrolling loads rows on demand as you scroll, ensuring smooth performance even with 10,000+ rows.
### Example 3: Dashboard with Multiple Grids
An application with multiple data grids in different tabs:
```python
import pandas as pd
from fasthtml.common import *
from myfasthtml.controls.DataGrid import DataGrid
from myfasthtml.controls.TabsManager import TabsManager
from myfasthtml.core.instances import RootInstance
# Create data for different views
sales_df = pd.DataFrame({
"Date": pd.date_range("2024-01-01", periods=30, freq="D"),
"Revenue": [1000 + i * 50 for i in range(30)],
"Orders": [10 + i for i in range(30)]
})
customers_df = pd.DataFrame({
"Customer": ["Acme Corp", "Tech Inc", "Global Ltd"],
"Country": ["USA", "UK", "Germany"],
"Total Spent": [15000, 12000, 8500]
})
# Create instances
root = RootInstance(session)
tabs = TabsManager(parent=root, _id="dashboard-tabs")
# Create grids
sales_grid = DataGrid(parent=root, _id="sales-grid")
sales_grid.init_from_dataframe(sales_df)
customers_grid = DataGrid(parent=root, _id="customers-grid")
customers_grid.init_from_dataframe(customers_df)
# Add to tabs
tabs.create_tab("Sales", sales_grid)
tabs.create_tab("Customers", customers_grid)
return Div(
H1("Sales Dashboard"),
tabs,
cls="p-4"
)
```
---
## Developer Reference
This section contains technical details for developers working on the DataGrid component itself.
### State
The DataGrid uses two state objects:
**DatagridState** - Main state for grid data and configuration:
| Name | Type | Description | Default |
|-------------------|---------------------------|----------------------------|---------|
| `sidebar_visible` | bool | Whether sidebar is visible | `False` |
| `row_index` | bool | Show row index column | `True` |
| `columns` | list[DataGridColumnState] | Column definitions | `[]` |
| `rows` | list[DataGridRowState] | Row-specific states | `[]` |
| `sorted` | list | Sort configuration | `[]` |
| `filtered` | dict | Active filters | `{}` |
| `selection` | DatagridSelectionState | Selection state | - |
| `ne_df` | DataFrame | The data (non-persisted) | `None` |
**DatagridSettings** - User preferences:
| Name | Type | Description | Default |
|----------------------|------|--------------------|---------|
| `save_state` | bool | Enable persistence | `False` |
| `header_visible` | bool | Show header row | `True` |
| `filter_all_visible` | bool | Show filter bar | `True` |
| `text_size` | str | Body text size | `"sm"` |
### Column State
Each column is represented by `DataGridColumnState`:
| Name | Type | Description | Default |
|-------------|------------|--------------------|---------|
| `col_id` | str | Column identifier | - |
| `col_index` | int | Index in DataFrame | - |
| `title` | str | Display title | `None` |
| `type` | ColumnType | Data type | `Text` |
| `visible` | bool | Is column visible | `True` |
| `usable` | bool | Is column usable | `True` |
| `width` | int | Width in pixels | `150` |
### Commands
Available commands for programmatic control:
| Name | Description |
|------------------------|---------------------------------------------|
| `get_page(page_index)` | Load a specific page of data (lazy loading) |
| `set_column_width()` | Update column width after resize |
| `move_column()` | Move column to new position |
| `filter()` | Apply current filter to grid |
### Public Methods
| Method | Description |
|-----------------------------------------------|----------------------------------------|
| `init_from_dataframe(df, init_state=True)` | Load data from pandas DataFrame |
| `set_column_width(col_id, width)` | Set column width programmatically |
| `move_column(source_col_id, target_col_id)` | Move column to new position |
| `filter()` | Apply filter and return partial render |
| `render()` | Render the complete grid |
| `render_partial(fragment, redraw_scrollbars)` | Render only part of the grid |
### High Level Hierarchical Structure
```
Div(id="{id}", cls="grid")
├── Div (filter bar)
│ └── DataGridQuery # Filter/search component
├── Div(id="tw_{id}", cls="dt2-table-wrapper")
│ ├── Div(id="t_{id}", cls="dt2-table")
│ │ ├── Div (dt2-header-container)
│ │ │ └── Div(id="th_{id}", cls="dt2-row dt2-header")
│ │ │ ├── Div (dt2-cell) # Column 1 header
│ │ │ ├── Div (dt2-cell) # Column 2 header
│ │ │ └── ...
│ │ ├── Div(id="tb_{id}", cls="dt2-body-container")
│ │ │ └── Div (dt2-body)
│ │ │ ├── Div (dt2-row) # Data row 1
│ │ │ ├── Div (dt2-row) # Data row 2
│ │ │ └── ...
│ │ └── Div (dt2-footer-container)
│ │ └── Div (dt2-row dt2-header) # Footer row
│ └── Div (dt2-scrollbars)
│ ├── Div (dt2-scrollbars-vertical-wrapper)
│ │ └── Div (dt2-scrollbars-vertical)
│ └── Div (dt2-scrollbars-horizontal-wrapper)
│ └── Div (dt2-scrollbars-horizontal)
└── Script # Initialization script
```
### Element IDs
| Pattern | Description |
|-----------------------|-------------------------------------|
| `{id}` | Root grid container |
| `tw_{id}` | Table wrapper (scrollbar container) |
| `t_{id}` | Table element |
| `th_{id}` | Header row |
| `tb_{id}` | Body container |
| `tf_{id}` | Footer row |
| `tsm_{id}` | Selection Manager |
| `tr_{id}-{row_index}` | Individual data row |
### Internal Methods
These methods are used internally for rendering:
| Method | Description |
|---------------------------------------------|----------------------------------------|
| `mk_headers()` | Renders the header row |
| `mk_body()` | Renders the body with first page |
| `mk_body_container()` | Renders the scrollable body container |
| `mk_body_content_page(page_index)` | Renders a specific page of rows |
| `mk_body_cell(col_pos, row_index, col_def)` | Renders a single cell |
| `mk_body_cell_content(...)` | Renders cell content with highlighting |
| `mk_footers()` | Renders the footer row |
| `mk_table()` | Renders the complete table structure |
| `mk_aggregation_cell(...)` | Renders footer aggregation cell |
| `_get_filtered_df()` | Returns filtered and sorted DataFrame |
| `_apply_sort(df)` | Applies sort configuration |
| `_apply_filter(df)` | Applies filter configuration |
### DataGridQuery Component
The filter bar is a separate component (`DataGridQuery`) with its own state:
| State Property | Type | Description | Default |
|----------------|------|-----------------------------------------|------------|
| `filter_type` | str | Current mode ("filter", "search", "ai") | `"filter"` |
| `query` | str | Current search text | `None` |
**Commands:**
| Command | Description |
|------------------------|-----------------------------|
| `change_filter_type()` | Cycle through filter modes |
| `on_filter_changed()` | Handle search input changes |
| `on_cancel_query()` | Clear the search query |

View File

@@ -176,12 +176,55 @@ You can use any HTMX attribute in the configuration object:
- `hx-target` - Target element selector - `hx-target` - Target element selector
- `hx-swap` - Swap strategy (innerHTML, outerHTML, etc.) - `hx-swap` - Swap strategy (innerHTML, outerHTML, etc.)
- `hx-vals` - Additional values to send (object) - `hx-vals` - Additional values to send (object)
- `hx-vals-extra` - Extra values to merge (see below)
- `hx-headers` - Custom headers (object) - `hx-headers` - Custom headers (object)
- `hx-select` - Select specific content from response - `hx-select` - Select specific content from response
- `hx-confirm` - Confirmation message - `hx-confirm` - Confirmation message
All other `hx-*` attributes are supported and will be converted to the appropriate htmx.ajax() parameters. All other `hx-*` attributes are supported and will be converted to the appropriate htmx.ajax() parameters.
### Dynamic Values with hx-vals-extra
The `hx-vals-extra` attribute allows adding dynamic values computed at event time, without overwriting the static `hx-vals`.
**Format:**
```javascript
{
"hx-vals": {"c_id": "command_id"}, // Static values (preserved)
"hx-vals-extra": {
"dict": {"key": "value"}, // Additional static values (merged)
"js": "functionName" // JS function to call (merged)
}
}
```
**How values are merged:**
1. `hx-vals` - static values (e.g., `c_id` from Command)
2. `hx-vals-extra.dict` - additional static values
3. `hx-vals-extra.js` - function called with `(event, element, combinationStr)`, result merged
**JavaScript function example:**
```javascript
function getKeyboardContext(event, element, combination) {
return {
key: event.key,
shift: event.shiftKey,
timestamp: Date.now()
};
}
```
**Configuration example:**
```javascript
const combinations = {
"Ctrl+S": {
"hx-post": "/save",
"hx-vals": {"c_id": "save_cmd"},
"hx-vals-extra": {"js": "getKeyboardContext"}
}
};
```
### Automatic Parameters ### Automatic Parameters
The library automatically adds these parameters to every request: The library automatically adds these parameters to every request:

View File

@@ -64,6 +64,70 @@ const combinations = {
add_mouse_support('my-element', JSON.stringify(combinations)); add_mouse_support('my-element', JSON.stringify(combinations));
``` ```
### Dynamic Values with JavaScript Functions
You can add dynamic values computed at click time using `hx-vals-extra`. This is useful when combined with a Command (which provides `hx-vals` with `c_id`).
**Configuration format:**
```javascript
const combinations = {
"click": {
"hx-post": "/myfasthtml/commands",
"hx-vals": {"c_id": "command_id"}, // Static values from Command
"hx-vals-extra": {"js": "getClickData"} // Dynamic values via JS function
}
};
```
**How it works:**
1. `hx-vals` contains static values (e.g., `c_id` from Command)
2. `hx-vals-extra.dict` contains additional static values (merged)
3. `hx-vals-extra.js` specifies a function to call for dynamic values (merged)
**JavaScript function definition:**
```javascript
// Function receives (event, element, combinationStr)
function getClickData(event, element, combination) {
return {
x: event.clientX,
y: event.clientY,
target_id: event.target.id,
timestamp: Date.now()
};
}
```
The function parameters are optional - use what you need:
```javascript
// Full context
function getFullContext(event, element, combination) {
return { x: event.clientX, elem: element.id, combo: combination };
}
// Just the event
function getPosition(event) {
return { x: event.clientX, y: event.clientY };
}
// No parameters needed
function getTimestamp() {
return { ts: Date.now() };
}
```
**Built-in helper function:**
```javascript
// getCellId() - finds parent with .dt2-cell class and returns its id
function getCellId(event) {
const cell = event.target.closest('.dt2-cell');
if (cell && cell.id) {
return { cell_id: cell.id };
}
return {};
}
```
## API Reference ## API Reference
### add_mouse_support(elementId, combinationsJson) ### add_mouse_support(elementId, combinationsJson)
@@ -150,16 +214,155 @@ The library automatically adds these parameters to every HTMX request:
## Python Integration ## Python Integration
### Basic Usage ### Mouse Class
The `Mouse` class provides a convenient way to add mouse support to elements.
```python ```python
from myfasthtml.controls.Mouse import Mouse
from myfasthtml.core.commands import Command
# Create mouse support for an element
mouse = Mouse(parent_element)
# Add combinations
mouse.add("click", select_command)
mouse.add("ctrl+click", toggle_command)
mouse.add("right_click", context_menu_command)
```
### Mouse.add() Method
```python
def add(self, sequence: str, command: Command = None, *,
hx_post: str = None, hx_get: str = None, hx_put: str = None,
hx_delete: str = None, hx_patch: str = None,
hx_target: str = None, hx_swap: str = None, hx_vals=None)
```
**Parameters**:
- `sequence`: Mouse event sequence (e.g., "click", "ctrl+click", "click right_click")
- `command`: Optional Command object for server-side action
- `hx_post`, `hx_get`, etc.: HTMX URL parameters (override command)
- `hx_target`: HTMX target selector (overrides command)
- `hx_swap`: HTMX swap strategy (overrides command)
- `hx_vals`: Additional HTMX values - dict or "js:functionName()" for dynamic values
**Note**:
- Named parameters (except `hx_vals`) override the command's parameters.
- `hx_vals` is **merged** with command's values (stored in `hx-vals-extra`), preserving `c_id`.
### Usage Patterns
**With Command only**:
```python
mouse.add("click", my_command)
```
**With Command and overrides**:
```python
# Command provides hx-post, but we override the target
mouse.add("ctrl+click", my_command, hx_target="#other-result")
```
**Without Command (direct HTMX)**:
```python
mouse.add("right_click", hx_post="/context-menu", hx_target="#menu", hx_swap="innerHTML")
```
**With dynamic values**:
```python
mouse.add("shift+click", my_command, hx_vals="js:getClickPosition()")
```
### Sequences
```python
mouse = Mouse(element)
mouse.add("click", single_click_command)
mouse.add("click click", double_click_command)
mouse.add("click right_click", special_action_command)
```
### Multiple Elements
```python
# Each element gets its own Mouse instance
for item in items:
mouse = Mouse(item)
mouse.add("click", Command("select", "Select item", lambda i=item: select(i)))
mouse.add("ctrl+click", Command("toggle", "Toggle item", lambda i=item: toggle(i)))
```
### Dynamic hx-vals with JavaScript
You can use `"js:functionName()"` to call a client-side JavaScript function that returns additional values to send with the request. The command's `c_id` is preserved.
**Python**:
```python
mouse.add("click", my_command, hx_vals="js:getClickContext()")
```
**Generated config** (internally):
```json
{
"hx-post": "/myfasthtml/commands",
"hx-vals": {"c_id": "command_id"},
"hx-vals-extra": {"js": "getClickContext"}
}
```
**JavaScript** (client-side):
```javascript
// Function receives (event, element, combinationStr)
function getClickContext(event, element, combination) {
return {
x: event.clientX,
y: event.clientY,
elementId: element.id,
combo: combination
};
}
// Simple function - parameters are optional
function getTimestamp() {
return { ts: Date.now() };
}
```
**Values sent to server**:
```json
{
"c_id": "command_id",
"x": 150,
"y": 200,
"elementId": "my-element",
"combo": "click",
"combination": "click",
"has_focus": false,
"is_inside": true
}
```
You can also pass a static dict:
```python
mouse.add("click", my_command, hx_vals={"extra_key": "extra_value"})
```
### Low-Level Usage (without Mouse class)
For advanced use cases, you can generate the JavaScript directly:
```python
import json
combinations = { combinations = {
"click": { "click": {
"hx-post": "/item/select" "hx-post": "/item/select"
}, },
"ctrl+click": { "ctrl+click": {
"hx-post": "/item/select-multiple", "hx-post": "/item/select-multiple",
"hx-vals": json.dumps({"mode": "multi"}) "hx-vals": {"mode": "multi"}
}, },
"right_click": { "right_click": {
"hx-post": "/item/context-menu", "hx-post": "/item/context-menu",
@@ -168,41 +371,7 @@ combinations = {
} }
} }
f"add_mouse_support('{element_id}', '{json.dumps(combinations)}')" Script(f"add_mouse_support('{element_id}', '{json.dumps(combinations)}')")
```
### Sequences
```python
combinations = {
"click": {
"hx-post": "/single-click"
},
"click click": {
"hx-post": "/double-click-sequence"
},
"click right_click": {
"hx-post": "/click-then-right-click"
}
}
```
### Multiple Elements
```python
# Item 1
item1_combinations = {
"click": {"hx-post": f"/item/1/select"},
"ctrl+click": {"hx-post": f"/item/1/toggle"}
}
f"add_mouse_support('item-1', '{json.dumps(item1_combinations)}')"
# Item 2
item2_combinations = {
"click": {"hx-post": f"/item/2/select"},
"ctrl+click": {"hx-post": f"/item/2/toggle"}
}
f"add_mouse_support('item-2', '{json.dumps(item2_combinations)}')"
``` ```
## Behavior Details ## Behavior Details

View File

@@ -2,6 +2,8 @@
--color-border-primary: color-mix(in oklab, var(--color-primary) 40%, #0000); --color-border-primary: color-mix(in oklab, var(--color-primary) 40%, #0000);
--color-border: color-mix(in oklab, var(--color-base-content) 20%, #0000); --color-border: color-mix(in oklab, var(--color-base-content) 20%, #0000);
--color-resize: color-mix(in oklab, var(--color-base-content) 50%, #0000); --color-resize: color-mix(in oklab, var(--color-base-content) 50%, #0000);
--color-selection: color-mix(in oklab, var(--color-primary) 20%, #0000);
--datagrid-resize-zindex: 1; --datagrid-resize-zindex: 1;
--font-sans: ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji'; --font-sans: ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
--font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace; --font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;
@@ -851,7 +853,7 @@
.dt2-row { .dt2-row {
display: flex; display: flex;
width: 100%; width: 100%;
height: 22px; height: 20px;
} }
/* Cell */ /* Cell */
@@ -928,6 +930,34 @@
color: var(--color-accent); color: var(--color-accent);
} }
.dt2-selected-focus {
outline: 2px solid var(--color-primary);
outline-offset: -3px; /* Ensure the outline is snug to the cell */
}
.dt2-cell:hover,
.dt2-selected-cell {
background-color: var(--color-selection);
}
.dt2-selected-row {
background-color: var(--color-selection);
}
.dt2-selected-column {
background-color: var(--color-selection);
}
.dt2-hover-row {
background-color: var(--color-selection);
}
.dt2-hover-column {
background-color: var(--color-selection);
}
/* *********************************************** */ /* *********************************************** */
/* ******** DataGrid Fixed Header/Footer ******** */ /* ******** DataGrid Fixed Header/Footer ******** */
/* *********************************************** */ /* *********************************************** */

View File

@@ -395,6 +395,128 @@ function updateTabs(controllerId) {
} }
} }
/**
* Find the parent element with .dt2-cell class and return its id.
* Used with hx-vals="js:getCellId()" for DataGrid cell identification.
*
* @param {MouseEvent} event - The mouse event
* @returns {Object} Object with cell_id property, or empty object if not found
*/
function getCellId(event) {
const cell = event.target.closest('.dt2-cell');
if (cell && cell.id) {
return {cell_id: cell.id};
}
return {cell_id: null};
}
/**
* Shared utility function for triggering HTMX actions from keyboard/mouse bindings.
* Handles dynamic hx-vals with "js:functionName()" syntax.
*
* @param {string} elementId - ID of the element
* @param {Object} config - HTMX configuration object
* @param {string} combinationStr - The matched combination string
* @param {boolean} isInside - Whether the focus/click is inside the element
* @param {Event} event - The event that triggered this action (KeyboardEvent or MouseEvent)
*/
function triggerHtmxAction(elementId, config, combinationStr, isInside, event) {
const element = document.getElementById(elementId);
if (!element) return;
const hasFocus = document.activeElement === element;
// Extract HTTP method and URL from hx-* attributes
let method = 'POST'; // default
let url = null;
const methodMap = {
'hx-post': 'POST',
'hx-get': 'GET',
'hx-put': 'PUT',
'hx-delete': 'DELETE',
'hx-patch': 'PATCH'
};
for (const [attr, httpMethod] of Object.entries(methodMap)) {
if (config[attr]) {
method = httpMethod;
url = config[attr];
break;
}
}
if (!url) {
console.error('No HTTP method attribute found in config:', config);
return;
}
// Build htmx.ajax options
const htmxOptions = {};
// Map hx-target to target
if (config['hx-target']) {
htmxOptions.target = config['hx-target'];
}
// Map hx-swap to swap
if (config['hx-swap']) {
htmxOptions.swap = config['hx-swap'];
}
// Map hx-vals to values and add combination, has_focus, and is_inside
const values = {};
// 1. Merge static hx-vals from command (if present)
if (config['hx-vals'] && typeof config['hx-vals'] === 'object') {
Object.assign(values, config['hx-vals']);
}
// 2. Merge hx-vals-extra (user overrides)
if (config['hx-vals-extra']) {
const extra = config['hx-vals-extra'];
// Merge static dict values
if (extra.dict && typeof extra.dict === 'object') {
Object.assign(values, extra.dict);
}
// Call dynamic JS function and merge result
if (extra.js) {
try {
const func = window[extra.js];
if (typeof func === 'function') {
const dynamicValues = func(event, element, combinationStr);
if (dynamicValues && typeof dynamicValues === 'object') {
Object.assign(values, dynamicValues);
}
} else {
console.error(`Function "${extra.js}" not found on window`);
}
} catch (e) {
console.error('Error calling dynamic hx-vals function:', e);
}
}
}
values.combination = combinationStr;
values.has_focus = hasFocus;
values.is_inside = isInside;
htmxOptions.values = values;
// Add any other hx-* attributes (like hx-headers, hx-select, etc.)
for (const [key, value] of Object.entries(config)) {
if (key.startsWith('hx-') && !['hx-post', 'hx-get', 'hx-put', 'hx-delete', 'hx-patch', 'hx-target', 'hx-swap', 'hx-vals'].includes(key)) {
// Remove 'hx-' prefix and convert to camelCase
const optionKey = key.substring(3).replace(/-([a-z])/g, (g) => g[1].toUpperCase());
htmxOptions[optionKey] = value;
}
}
// Make AJAX call with htmx
htmx.ajax(method, url, htmxOptions);
}
/** /**
* Create keyboard bindings * Create keyboard bindings
*/ */
@@ -553,80 +675,6 @@ function updateTabs(controllerId) {
return false; return false;
} }
/**
* Trigger an action for a matched combination
* @param {string} elementId - ID of the element
* @param {Object} config - HTMX configuration object
* @param {string} combinationStr - The matched combination string
* @param {boolean} isInside - Whether the focus is inside the element
*/
function triggerAction(elementId, config, combinationStr, isInside) {
const element = document.getElementById(elementId);
if (!element) return;
const hasFocus = document.activeElement === element;
// Extract HTTP method and URL from hx-* attributes
let method = 'POST'; // default
let url = null;
const methodMap = {
'hx-post': 'POST',
'hx-get': 'GET',
'hx-put': 'PUT',
'hx-delete': 'DELETE',
'hx-patch': 'PATCH'
};
for (const [attr, httpMethod] of Object.entries(methodMap)) {
if (config[attr]) {
method = httpMethod;
url = config[attr];
break;
}
}
if (!url) {
console.error('No HTTP method attribute found in config:', config);
return;
}
// Build htmx.ajax options
const htmxOptions = {};
// Map hx-target to target
if (config['hx-target']) {
htmxOptions.target = config['hx-target'];
}
// Map hx-swap to swap
if (config['hx-swap']) {
htmxOptions.swap = config['hx-swap'];
}
// Map hx-vals to values and add combination, has_focus, and is_inside
const values = {};
if (config['hx-vals']) {
Object.assign(values, config['hx-vals']);
}
values.combination = combinationStr;
values.has_focus = hasFocus;
values.is_inside = isInside;
htmxOptions.values = values;
// Add any other hx-* attributes (like hx-headers, hx-select, etc.)
for (const [key, value] of Object.entries(config)) {
if (key.startsWith('hx-') && !['hx-post', 'hx-get', 'hx-put', 'hx-delete', 'hx-patch', 'hx-target', 'hx-swap', 'hx-vals'].includes(key)) {
// Remove 'hx-' prefix and convert to camelCase
const optionKey = key.substring(3).replace(/-([a-z])/g, (g) => g[1].toUpperCase());
htmxOptions[optionKey] = value;
}
}
// Make AJAX call with htmx
htmx.ajax(method, url, htmxOptions);
}
/** /**
* Handle keyboard events and trigger matching combinations * Handle keyboard events and trigger matching combinations
* @param {KeyboardEvent} event - The keyboard event * @param {KeyboardEvent} event - The keyboard event
@@ -710,7 +758,7 @@ function updateTabs(controllerId) {
// We have matches and NO element has longer sequences possible // We have matches and NO element has longer sequences possible
// Trigger ALL matches immediately // Trigger ALL matches immediately
for (const match of currentMatches) { for (const match of currentMatches) {
triggerAction(match.elementId, match.config, match.combinationStr, match.isInside); triggerHtmxAction(match.elementId, match.config, match.combinationStr, match.isInside, event);
} }
// Clear history after triggering // Clear history after triggering
@@ -721,11 +769,12 @@ function updateTabs(controllerId) {
// Wait for timeout - ALL current matches will be triggered if timeout expires // Wait for timeout - ALL current matches will be triggered if timeout expires
KeyboardRegistry.pendingMatches = currentMatches; KeyboardRegistry.pendingMatches = currentMatches;
const savedEvent = event; // Save event for timeout callback
KeyboardRegistry.pendingTimeout = setTimeout(() => { KeyboardRegistry.pendingTimeout = setTimeout(() => {
// Timeout expired, trigger ALL pending matches // Timeout expired, trigger ALL pending matches
for (const match of KeyboardRegistry.pendingMatches) { for (const match of KeyboardRegistry.pendingMatches) {
triggerAction(match.elementId, match.config, match.combinationStr, match.isInside); triggerHtmxAction(match.elementId, match.config, match.combinationStr, match.isInside, savedEvent);
} }
// Clear state // Clear state
@@ -1051,80 +1100,6 @@ function updateTabs(controllerId) {
return actions; return actions;
} }
/**
* Trigger an action for a matched combination
* @param {string} elementId - ID of the element
* @param {Object} config - HTMX configuration object
* @param {string} combinationStr - The matched combination string
* @param {boolean} isInside - Whether the click was inside the element
*/
function triggerAction(elementId, config, combinationStr, isInside) {
const element = document.getElementById(elementId);
if (!element) return;
const hasFocus = document.activeElement === element;
// Extract HTTP method and URL from hx-* attributes
let method = 'POST'; // default
let url = null;
const methodMap = {
'hx-post': 'POST',
'hx-get': 'GET',
'hx-put': 'PUT',
'hx-delete': 'DELETE',
'hx-patch': 'PATCH'
};
for (const [attr, httpMethod] of Object.entries(methodMap)) {
if (config[attr]) {
method = httpMethod;
url = config[attr];
break;
}
}
if (!url) {
console.error('No HTTP method attribute found in config:', config);
return;
}
// Build htmx.ajax options
const htmxOptions = {};
// Map hx-target to target
if (config['hx-target']) {
htmxOptions.target = config['hx-target'];
}
// Map hx-swap to swap
if (config['hx-swap']) {
htmxOptions.swap = config['hx-swap'];
}
// Map hx-vals to values and add combination, has_focus, and is_inside
const values = {};
if (config['hx-vals']) {
Object.assign(values, config['hx-vals']);
}
values.combination = combinationStr;
values.has_focus = hasFocus;
values.is_inside = isInside;
htmxOptions.values = values;
// Add any other hx-* attributes (like hx-headers, hx-select, etc.)
for (const [key, value] of Object.entries(config)) {
if (key.startsWith('hx-') && !['hx-post', 'hx-get', 'hx-put', 'hx-delete', 'hx-patch', 'hx-target', 'hx-swap', 'hx-vals'].includes(key)) {
// Remove 'hx-' prefix and convert to camelCase
const optionKey = key.substring(3).replace(/-([a-z])/g, (g) => g[1].toUpperCase());
htmxOptions[optionKey] = value;
}
}
// Make AJAX call with htmx
htmx.ajax(method, url, htmxOptions);
}
/** /**
* Handle mouse events and trigger matching combinations * Handle mouse events and trigger matching combinations
* @param {MouseEvent} event - The mouse event * @param {MouseEvent} event - The mouse event
@@ -1223,7 +1198,7 @@ function updateTabs(controllerId) {
// We have matches and NO longer sequences possible // We have matches and NO longer sequences possible
// Trigger ALL matches immediately // Trigger ALL matches immediately
for (const match of currentMatches) { for (const match of currentMatches) {
triggerAction(match.elementId, match.config, match.combinationStr, match.isInside); triggerHtmxAction(match.elementId, match.config, match.combinationStr, match.isInside, event);
} }
// Clear history after triggering // Clear history after triggering
@@ -1234,11 +1209,12 @@ function updateTabs(controllerId) {
// Wait for timeout - ALL current matches will be triggered if timeout expires // Wait for timeout - ALL current matches will be triggered if timeout expires
MouseRegistry.pendingMatches = currentMatches; MouseRegistry.pendingMatches = currentMatches;
const savedEvent = event; // Save event for timeout callback
MouseRegistry.pendingTimeout = setTimeout(() => { MouseRegistry.pendingTimeout = setTimeout(() => {
// Timeout expired, trigger ALL pending matches // Timeout expired, trigger ALL pending matches
for (const match of MouseRegistry.pendingMatches) { for (const match of MouseRegistry.pendingMatches) {
triggerAction(match.elementId, match.config, match.combinationStr, match.isInside); triggerHtmxAction(match.elementId, match.config, match.combinationStr, match.isInside, savedEvent);
} }
// Clear state // Clear state
@@ -1267,9 +1243,8 @@ function updateTabs(controllerId) {
MouseRegistry.snapshotHistory = []; MouseRegistry.snapshotHistory = [];
} }
// DEBUG: Log click handler performance // Warn if click handler is slow
const clickDuration = performance.now() - clickStart; const clickDuration = performance.now() - clickStart;
console.warn(`🖱️ Click handler DONE: ${clickDuration.toFixed(2)}ms (${iterationCount} iterations, ${currentMatches.length} matches)`);
if (clickDuration > 100) { if (clickDuration > 100) {
console.warn(`⚠️ SLOW CLICK HANDLER: ${clickDuration.toFixed(2)}ms for ${elementCount} elements`); console.warn(`⚠️ SLOW CLICK HANDLER: ${clickDuration.toFixed(2)}ms for ${elementCount} elements`);
} }
@@ -1361,7 +1336,7 @@ function updateTabs(controllerId) {
// We have matches and NO longer sequences possible // We have matches and NO longer sequences possible
// Trigger ALL matches immediately // Trigger ALL matches immediately
for (const match of currentMatches) { for (const match of currentMatches) {
triggerAction(match.elementId, match.config, match.combinationStr, match.isInside); triggerHtmxAction(match.elementId, match.config, match.combinationStr, match.isInside, event);
} }
// Clear history after triggering // Clear history after triggering
@@ -1372,11 +1347,12 @@ function updateTabs(controllerId) {
// Wait for timeout - ALL current matches will be triggered if timeout expires // Wait for timeout - ALL current matches will be triggered if timeout expires
MouseRegistry.pendingMatches = currentMatches; MouseRegistry.pendingMatches = currentMatches;
const savedEvent = event; // Save event for timeout callback
MouseRegistry.pendingTimeout = setTimeout(() => { MouseRegistry.pendingTimeout = setTimeout(() => {
// Timeout expired, trigger ALL pending matches // Timeout expired, trigger ALL pending matches
for (const match of MouseRegistry.pendingMatches) { for (const match of MouseRegistry.pendingMatches) {
triggerAction(match.elementId, match.config, match.combinationStr, match.isInside); triggerHtmxAction(match.elementId, match.config, match.combinationStr, match.isInside, savedEvent);
} }
// Clear state // Clear state
@@ -1495,8 +1471,14 @@ function updateTabs(controllerId) {
function initDataGrid(gridId) { function initDataGrid(gridId) {
initDataGridScrollbars(gridId); initDataGridScrollbars(gridId);
initDataGridMouseOver(gridId);
makeDatagridColumnsResizable(gridId); makeDatagridColumnsResizable(gridId);
makeDatagridColumnsMovable(gridId); makeDatagridColumnsMovable(gridId);
updateDatagridSelection(gridId)
}
function initDataGridMouseOver(gridId) {
} }
/** /**
@@ -1656,7 +1638,7 @@ function initDataGridScrollbars(gridId) {
dragStartY = e.clientY; dragStartY = e.clientY;
dragStartScrollTop = cachedBodyScrollTop; dragStartScrollTop = cachedBodyScrollTop;
wrapper.setAttribute("mf-no-tooltip", ""); wrapper.setAttribute("mf-no-tooltip", "");
}, { signal }); }, {signal});
// Horizontal scrollbar mousedown // Horizontal scrollbar mousedown
horizontalScrollbar.addEventListener("mousedown", (e) => { horizontalScrollbar.addEventListener("mousedown", (e) => {
@@ -1664,7 +1646,7 @@ function initDataGridScrollbars(gridId) {
dragStartX = e.clientX; dragStartX = e.clientX;
dragStartScrollLeft = cachedTableScrollLeft; dragStartScrollLeft = cachedTableScrollLeft;
wrapper.setAttribute("mf-no-tooltip", ""); wrapper.setAttribute("mf-no-tooltip", "");
}, { signal }); }, {signal});
// Consolidated mousemove listener // Consolidated mousemove listener
document.addEventListener("mousemove", (e) => { document.addEventListener("mousemove", (e) => {
@@ -1695,7 +1677,7 @@ function initDataGridScrollbars(gridId) {
}); });
} }
} }
}, { signal }); }, {signal});
// Consolidated mouseup listener // Consolidated mouseup listener
document.addEventListener("mouseup", () => { document.addEventListener("mouseup", () => {
@@ -1706,7 +1688,7 @@ function initDataGridScrollbars(gridId) {
isDraggingHorizontal = false; isDraggingHorizontal = false;
wrapper.removeAttribute("mf-no-tooltip"); wrapper.removeAttribute("mf-no-tooltip");
} }
}, { signal }); }, {signal});
// Wheel scrolling - OPTIMIZED with RAF throttling // Wheel scrolling - OPTIMIZED with RAF throttling
let rafScheduledWheel = false; let rafScheduledWheel = false;
@@ -1759,7 +1741,7 @@ function initDataGridScrollbars(gridId) {
updateScrollbars(); updateScrollbars();
}); });
} }
}, { signal }); }, {signal});
} }
function makeDatagridColumnsResizable(datagridId) { function makeDatagridColumnsResizable(datagridId) {
@@ -2022,3 +2004,44 @@ function moveColumn(table, sourceColId, targetColId) {
}); });
}, ANIMATION_DURATION); }, ANIMATION_DURATION);
} }
function updateDatagridSelection(datagridId) {
const selectionManager = document.getElementById(`tsm_${datagridId}`);
if (!selectionManager) return;
// Clear previous selections
document.querySelectorAll('.dt2-selected-focus, .dt2-selected-cell, .dt2-selected-row, .dt2-selected-column').forEach((element) => {
element.classList.remove('dt2-selected-focus', 'dt2-selected-cell', 'dt2-selected-row', 'dt2-selected-column');
element.style.userSelect = 'none';
});
// Loop through the children of the selection manager
Array.from(selectionManager.children).forEach((selection) => {
const selectionType = selection.getAttribute('selection-type');
const elementId = selection.getAttribute('element-id');
if (selectionType === 'focus') {
const cellElement = document.getElementById(`${elementId}`);
if (cellElement) {
cellElement.classList.add('dt2-selected-focus');
cellElement.style.userSelect = 'text';
}
} else if (selectionType === 'cell') {
const cellElement = document.getElementById(`${elementId}`);
if (cellElement) {
cellElement.classList.add('dt2-selected-cell');
cellElement.style.userSelect = 'text';
}
} else if (selectionType === 'row') {
const rowElement = document.getElementById(`${elementId}`);
if (rowElement) {
rowElement.classList.add('dt2-selected-row');
}
} else if (selectionType === 'column') {
// Select all elements in the specified column
document.querySelectorAll(`[data-col="${elementId}"]`).forEach((columnElement) => {
columnElement.classList.add('dt2-selected-column');
});
}
});
}

View File

@@ -11,6 +11,7 @@ from pandas import DataFrame
from myfasthtml.controls.BaseCommands import BaseCommands from myfasthtml.controls.BaseCommands import BaseCommands
from myfasthtml.controls.DataGridQuery import DataGridQuery, DG_QUERY_FILTER from myfasthtml.controls.DataGridQuery import DataGridQuery, DG_QUERY_FILTER
from myfasthtml.controls.Mouse import Mouse
from myfasthtml.controls.datagrid_objects import DataGridColumnState, DataGridRowState, \ from myfasthtml.controls.datagrid_objects import DataGridColumnState, DataGridRowState, \
DatagridSelectionState, DataGridHeaderFooterConf, DatagridEditionState DatagridSelectionState, DataGridHeaderFooterConf, DatagridEditionState
from myfasthtml.controls.helpers import mk from myfasthtml.controls.helpers import mk
@@ -110,6 +111,13 @@ class Commands(BaseCommands):
self._owner, self._owner,
self._owner.filter self._owner.filter
) )
def on_click(self):
return Command("OnClick",
"Click on the table",
self._owner,
self._owner.on_click
).htmx(target=f"#tsm_{self._id}")
class DataGrid(MultipleInstance): class DataGrid(MultipleInstance):
@@ -121,6 +129,11 @@ class DataGrid(MultipleInstance):
self.init_from_dataframe(self._state.ne_df, init_state=False) # state comes from DatagridState self.init_from_dataframe(self._state.ne_df, init_state=False) # state comes from DatagridState
self._datagrid_filter = DataGridQuery(self) self._datagrid_filter = DataGridQuery(self)
self._datagrid_filter.bind_command("QueryChanged", self.commands.filter()) self._datagrid_filter.bind_command("QueryChanged", self.commands.filter())
self._datagrid_filter.bind_command("CancelQuery", self.commands.filter())
self._datagrid_filter.bind_command("ChangeFilterType", self.commands.filter())
# update the filter
self._state.filtered[FILTER_INPUT_CID] = self._datagrid_filter.get_query()
@property @property
def _df(self): def _df(self):
@@ -169,6 +182,31 @@ class DataGrid(MultipleInstance):
return df return df
def _get_element_id_from_pos(self, selection_mode, pos):
if pos is None or pos == (None, None):
return None
elif selection_mode == "row":
return f"trow_{self._id}-{pos[0]}"
elif selection_mode == "column":
return f"tcol_{self._id}-{pos[1]}"
else:
return f"tcell_{self._id}-{pos[0]}-{pos[1]}"
def _get_pos_from_element_id(self, element_id):
if element_id is None:
return None
if element_id.startswith("tcell_"):
parts = element_id.split("-")
return int(parts[-2]), int(parts[-1])
return None
def _update_current_position(self, pos):
self._state.selection.last_selected = self._state.selection.selected
self._state.selection.selected = pos
self._state.save()
def init_from_dataframe(self, df, init_state=True): def init_from_dataframe(self, df, init_state=True):
def _get_column_type(dtype): def _get_column_type(dtype):
@@ -271,7 +309,16 @@ class DataGrid(MultipleInstance):
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()
return self.mk_body_container(redraw_scrollbars=True) return self.render_partial("body", redraw_scrollbars=True)
def on_click(self, combination, is_inside, cell_id):
logger.debug(f"on_click {combination=} {is_inside=} {cell_id=}")
if is_inside and cell_id:
if cell_id.startswith("tcell_"):
pos = self._get_pos_from_element_id(cell_id)
self._update_current_position(pos)
return self.render_partial()
def mk_headers(self): def mk_headers(self):
resize_cmd = self.commands.set_column_width() resize_cmd = self.commands.set_column_width()
@@ -375,6 +422,7 @@ class DataGrid(MultipleInstance):
data_col=col_def.col_id, data_col=col_def.col_id,
data_tooltip=str(value), data_tooltip=str(value),
style=f"width:{col_def.width}px;", style=f"width:{col_def.width}px;",
id=self._get_element_id_from_pos("cell", (row_index, col_pos)),
cls="dt2-cell") cls="dt2-cell")
def mk_body_content_page(self, page_index: int): def mk_body_content_page(self, page_index: int):
@@ -404,12 +452,11 @@ class DataGrid(MultipleInstance):
return rows return rows
def mk_body_container(self, redraw_scrollbars=False): def mk_body_container(self):
return Div( return Div(
self.mk_body(), self.mk_body(),
Script(f"initDataGridScrollbars('{self._id}');") if redraw_scrollbars else None,
cls="dt2-body-container", cls="dt2-body-container",
id=f"tb_{self._id}" id=f"tb_{self._id}",
) )
def mk_body(self): def mk_body(self):
@@ -433,6 +480,8 @@ class DataGrid(MultipleInstance):
def mk_table(self): def mk_table(self):
return Div( return Div(
self.mk_selection_manager(),
# Grid table with header, body, footer # Grid table with header, body, footer
Div( Div(
# Header container - no scroll # Header container - no scroll
@@ -469,6 +518,25 @@ class DataGrid(MultipleInstance):
id=f"tw_{self._id}" id=f"tw_{self._id}"
) )
def mk_selection_manager(self):
extra_attr = {
"hx-on::after-settle": f"updateDatagridSelection('{self._id}');",
}
selected = []
if self._state.selection.selected:
#selected.append(("cell", self._get_element_id_from_pos("cell", self._state.selection.selected)))
selected.append(("focus", self._get_element_id_from_pos("cell", self._state.selection.selected)))
return Div(
*[Div(selection_type=s_type, element_id=f"{elt_id}") for s_type, elt_id in selected],
id=f"tsm_{self._id}",
selection_mode=f"{self._state.selection.selection_mode}",
**extra_attr,
)
def mk_aggregation_cell(self, col_def, row_index: int, footer_conf, oob=False): def mk_aggregation_cell(self, col_def, row_index: int, footer_conf, oob=False):
""" """
Generates a footer cell for a data table based on the provided column definition, Generates a footer cell for a data table based on the provided column definition,
@@ -535,14 +603,43 @@ class DataGrid(MultipleInstance):
if self._state.ne_df is None: if self._state.ne_df is None:
return Div("No data to display !") return Div("No data to display !")
mouse_support = {
"click": {"command": self.commands.on_click(), "hx_vals": "js:getCellId()"},
"ctrl+click": {"command": self.commands.on_click(), "hx_vals": "js:getCellId()"},
"shift+click": {"command": self.commands.on_click(), "hx_vals": "js:getCellId()"},
}
return Div( return Div(
Div(self._datagrid_filter, cls="mb-2"), Div(self._datagrid_filter, cls="mb-2"),
self.mk_table(), self.mk_table(),
Script(f"initDataGrid('{self._id}');"), Script(f"initDataGrid('{self._id}');"),
Mouse(self, combinations=mouse_support),
id=self._id, id=self._id,
cls="grid", cls="grid",
style="height: 100%; grid-template-rows: auto 1fr;" style="height: 100%; grid-template-rows: auto 1fr;"
) )
def render_partial(self, fragment="cell", redraw_scrollbars=False):
"""
:param fragment: cell | body
:param redraw_scrollbars:
:return:
"""
res = []
extra_attr = {
"hx-on::after-settle": f"initDataGridScrollbars('{self._id}');",
}
if fragment == "body":
body_container = self.mk_body_container()
body_container.attrs.update(extra_attr)
res.append(body_container)
res.append(self.mk_selection_manager())
return tuple(res)
def __ft__(self): def __ft__(self):
return self.render() return self.render()

View File

@@ -75,7 +75,7 @@ class DataGridQuery(MultipleInstance):
def query_changed(self, query): def query_changed(self, query):
logger.debug(f"query_changed {query=}") logger.debug(f"query_changed {query=}")
self._state.query = query self._state.query = query.strip() if query is not None else None
return self return self
def render(self): def render(self):

View File

@@ -12,17 +12,112 @@ class Mouse(MultipleInstance):
This class is used to add, manage, and render mouse event sequences with corresponding This class is used to add, manage, and render mouse event sequences with corresponding
commands, providing a flexible way to handle mouse interactions programmatically. commands, providing a flexible way to handle mouse interactions programmatically.
Combinations can be defined with:
- A Command object: mouse.add("click", command)
- HTMX parameters: mouse.add("click", hx_post="/url", hx_vals={...})
- Both (named params override command): mouse.add("click", command, hx_target="#other")
For dynamic hx_vals, use "js:functionName()" to call a client-side function.
""" """
def __init__(self, parent, _id=None, combinations=None): def __init__(self, parent, _id=None, combinations=None):
super().__init__(parent, _id=_id) super().__init__(parent, _id=_id)
self.combinations = combinations or {} self.combinations = combinations or {}
def add(self, sequence: str, command: Command): def add(self, sequence: str, command: Command = None, *,
self.combinations[sequence] = command hx_post: str = None, hx_get: str = None, hx_put: str = None,
hx_delete: str = None, hx_patch: str = None,
hx_target: str = None, hx_swap: str = None, hx_vals=None):
"""
Add a mouse combination with optional command and HTMX parameters.
Args:
sequence: Mouse event sequence (e.g., "click", "ctrl+click", "click right_click")
command: Optional Command object for server-side action
hx_post: HTMX post URL (overrides command)
hx_get: HTMX get URL (overrides command)
hx_put: HTMX put URL (overrides command)
hx_delete: HTMX delete URL (overrides command)
hx_patch: HTMX patch URL (overrides command)
hx_target: HTMX target selector (overrides command)
hx_swap: HTMX swap strategy (overrides command)
hx_vals: HTMX values dict or "js:functionName()" for dynamic values
Returns:
self for method chaining
"""
self.combinations[sequence] = {
"command": command,
"hx_post": hx_post,
"hx_get": hx_get,
"hx_put": hx_put,
"hx_delete": hx_delete,
"hx_patch": hx_patch,
"hx_target": hx_target,
"hx_swap": hx_swap,
"hx_vals": hx_vals,
}
return self return self
def _build_htmx_params(self, combination_data: dict) -> dict:
"""
Build HTMX parameters by merging command params with named overrides.
Named parameters take precedence over command parameters.
hx_vals is handled separately via hx-vals-extra to preserve command's hx-vals.
"""
command = combination_data.get("command")
# Start with command params if available
if command is not None:
params = command.get_htmx_params().copy()
else:
params = {}
# Override with named parameters (only if explicitly set)
# Note: hx_vals is handled separately below
param_mapping = {
"hx_post": "hx-post",
"hx_get": "hx-get",
"hx_put": "hx-put",
"hx_delete": "hx-delete",
"hx_patch": "hx-patch",
"hx_target": "hx-target",
"hx_swap": "hx-swap",
}
for py_name, htmx_name in param_mapping.items():
value = combination_data.get(py_name)
if value is not None:
params[htmx_name] = value
# Handle hx_vals separately - store in hx-vals-extra to not overwrite command's hx-vals
hx_vals = combination_data.get("hx_vals")
if hx_vals is not None:
if isinstance(hx_vals, str) and hx_vals.startswith("js:"):
# Dynamic values: extract function name
func_name = hx_vals[3:].rstrip("()")
params["hx-vals-extra"] = {"js": func_name}
elif isinstance(hx_vals, dict):
# Static dict values
params["hx-vals-extra"] = {"dict": hx_vals}
else:
# Other string values - try to parse as JSON
try:
parsed = json.loads(hx_vals)
if not isinstance(parsed, dict):
raise ValueError(f"hx_vals must be a dict, got {type(parsed).__name__}")
params["hx-vals-extra"] = {"dict": parsed}
except json.JSONDecodeError as e:
raise ValueError(f"hx_vals must be a dict or 'js:functionName()', got invalid JSON: {e}")
return params
def render(self): def render(self):
str_combinations = {sequence: command.get_htmx_params() for sequence, command in self.combinations.items()} str_combinations = {
sequence: self._build_htmx_params(data)
for sequence, data in self.combinations.items()
}
return Script(f"add_mouse_support('{self._parent.get_id()}', '{json.dumps(str_combinations)}')") return Script(f"add_mouse_support('{self._parent.get_id()}', '{json.dumps(str_combinations)}')")
def __ft__(self): def __ft__(self):

View File

@@ -9,6 +9,7 @@ class DataGridRowState:
visible: bool = True visible: bool = True
height: int | None = None height: int | None = None
@dataclass @dataclass
class DataGridColumnState: class DataGridColumnState:
col_id: str # name of the column: cannot be changed col_id: str # name of the column: cannot be changed
@@ -40,8 +41,6 @@ class DataGridHeaderFooterConf:
conf: dict[str, str] = field(default_factory=dict) # first 'str' is the column id conf: dict[str, str] = field(default_factory=dict) # first 'str' is the column id
@dataclass @dataclass
class DatagridView: class DatagridView:
name: str name: str