I can click on the grid to select a cell
This commit is contained in:
601
docs/DataGrid.md
Normal file
601
docs/DataGrid.md
Normal 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 |
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 ******** */
|
||||||
/* *********************************************** */
|
/* *********************************************** */
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user